├── .babelrc
├── .gitignore
├── LICENSE
├── README.md
├── docs
└── assets
│ ├── demo.gif
│ ├── logo.png
│ ├── logo@2x.png
│ └── logo@3x.png
├── package.json
├── src
├── assets
│ └── iphone-6-silver.svg
├── babel-worker.js
├── components
│ ├── embed
│ │ └── Playground.tsx
│ ├── player
│ │ ├── ConsoleProxy.ts
│ │ └── VendorComponents.ts
│ └── workspace
│ │ ├── About.tsx
│ │ ├── App.tsx
│ │ ├── Button.tsx
│ │ ├── CodeSandboxButton.tsx
│ │ ├── Console.tsx
│ │ ├── Editor.tsx
│ │ ├── Header.tsx
│ │ ├── HeaderLink.tsx
│ │ ├── Icons.tsx
│ │ ├── Inspector.tsx
│ │ ├── Overlay.tsx
│ │ ├── Phone.tsx
│ │ ├── PlayerFrame.tsx
│ │ ├── PlaygroundPreview.tsx
│ │ ├── Spacer.tsx
│ │ ├── Status.tsx
│ │ ├── TabContainer.tsx
│ │ ├── Tabs.tsx
│ │ ├── Tooltip.tsx
│ │ ├── Workspace.tsx
│ │ ├── WorkspacesList.tsx
│ │ └── panes
│ │ ├── ConsolePane.tsx
│ │ ├── EditorPane.tsx
│ │ ├── PlayerPane.tsx
│ │ ├── StackPane.tsx
│ │ ├── TranspilerPane.tsx
│ │ └── WorkspacesPane.tsx
├── constants
│ ├── DefaultCode.ts
│ └── Phones.ts
├── contexts
│ └── OptionsContext.ts
├── environments
│ ├── IEnvironment.tsx
│ ├── html-environment.tsx
│ ├── javascript-environment.tsx
│ ├── python-environment.tsx
│ ├── react-environment.tsx
│ └── react-native-environment.tsx
├── hooks
│ ├── useRerenderEffect.ts
│ ├── useResponsiveBreakpoint.ts
│ └── useWindowDimensions.ts
├── index.tsx
├── player.tsx
├── python-worker.js
├── reducers
│ └── workspace.ts
├── styles
│ ├── codemirror-theme.css
│ ├── index.css
│ ├── player.css
│ └── reset.css
├── types
│ ├── Messages.ts
│ ├── globals.d.ts
│ ├── react-native-web.d.ts
│ ├── react.d.ts
│ ├── snarkdown.d.ts
│ └── svg.d.ts
├── typescript-worker.ts
├── utils
│ ├── BabelConsolePlugin.ts
│ ├── BabelExpressionLogPlugin.ts
│ ├── BabelInfiniteLoopPlugin.ts
│ ├── BabelRequest.ts
│ ├── CSS.ts
│ ├── CodeMirror.ts
│ ├── CodeMirrorTooltipAddon.ts
│ ├── DOMCoding.ts
│ ├── Diff.ts
│ ├── ErrorMessage.ts
│ ├── ExtendedJSON.ts
│ ├── HashString.ts
│ ├── MockDOM.ts
│ ├── Networking.ts
│ ├── Object.ts
│ ├── Panes.ts
│ ├── PlayerUtils.ts
│ ├── Styles.ts
│ ├── Tab.ts
│ ├── TypeScriptDefaultConfig.ts
│ ├── TypeScriptDefaultLibs.ts
│ ├── TypeScriptRequest.ts
│ ├── WorkerRequest.ts
│ ├── formatError.ts
│ ├── hasProperty.ts
│ ├── inspect.tsx
│ ├── options.ts
│ ├── path.ts
│ ├── playerCommunication.ts
│ └── queryString.ts
└── workers
│ ├── pythonWorker.ts
│ └── typescript
│ ├── LanguageServiceHost.ts
│ ├── fileSystem.ts
│ └── system.ts
├── tsconfig.json
├── webpack
├── empty.js
├── index.ejs
├── webpack-embed.config.js
└── webpack.config.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "stage-1", "react"]
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 |
3 | node_modules
4 | yarn-error.log
5 |
6 | dist
7 | public
8 | /report.*.json
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD License
2 |
3 | Copyright (c) 2016-present, Devin Abbott. All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without modification,
6 | are permitted provided that the following conditions are met:
7 |
8 | * Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | * Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | * Neither the name of the copyright holder nor the names of its contributors
16 | may be used to endorse or promote products derived from this software without
17 | specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/docs/assets/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dabbott/javascript-playgrounds/2f77a8120dd45bb0b422c4ebfcdf464509f84a9b/docs/assets/demo.gif
--------------------------------------------------------------------------------
/docs/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dabbott/javascript-playgrounds/2f77a8120dd45bb0b422c4ebfcdf464509f84a9b/docs/assets/logo.png
--------------------------------------------------------------------------------
/docs/assets/logo@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dabbott/javascript-playgrounds/2f77a8120dd45bb0b422c4ebfcdf464509f84a9b/docs/assets/logo@2x.png
--------------------------------------------------------------------------------
/docs/assets/logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dabbott/javascript-playgrounds/2f77a8120dd45bb0b422c4ebfcdf464509f84a9b/docs/assets/logo@3x.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "javascript-playgrounds",
3 | "version": "1.2.8",
4 | "description": "Interactive JavaScript sandbox",
5 | "main": "dist/javascript-playgrounds.js",
6 | "files": [
7 | "dist",
8 | "public"
9 | ],
10 | "types": "dist/src/components/embed/Playground.d.ts",
11 | "scripts": {
12 | "test": "echo \"Error: no test specified\" && exit 1",
13 | "dev": "webpack-dev-server --config webpack/webpack.config.js --inline --hot --colors --quiet",
14 | "start": "python3 -m http.server -d public 8080",
15 | "build": "npm run build:core && npm run build:embed",
16 | "build:core": "webpack --env production --config webpack/webpack.config.js --sort-assets-by --progress",
17 | "build:embed": "webpack --config webpack/webpack-embed.config.js --sort-assets-by --progress",
18 | "clean": "rm -rf ./dist ./public",
19 | "prepublishOnly": "npm run clean && npm run build",
20 | "gh-pages": "npm run prepublish && gh-pages -d public",
21 | "analyze": "webpack --env production --config webpack/webpack.config.js --sort-assets-by --progress --profile --json > stats.json"
22 | },
23 | "repository": {
24 | "type": "git",
25 | "url": "git+https://github.com/dabbott/javascript-playgrounds.git"
26 | },
27 | "author": "devinabbott@gmail.com",
28 | "license": "BSD-3-Clause",
29 | "bugs": {
30 | "url": "https://github.com/dabbott/javascript-playgrounds/issues"
31 | },
32 | "homepage": "https://github.com/dabbott/javascript-playgrounds#readme",
33 | "devDependencies": {
34 | "@babel/core": "^7.9.6",
35 | "@juggle/resize-observer": "^3.1.3",
36 | "@types/babel__core": "^7.1.10",
37 | "@types/codemirror": "^0.0.97",
38 | "@types/diff": "^4.0.2",
39 | "@types/inline-style-prefixer": "^5.0.0",
40 | "@types/react": "^16.9.48",
41 | "@types/react-dom": "^16.9.8",
42 | "@types/react-inspector": "^4.0.1",
43 | "@types/react-loadable": "^5.5.3",
44 | "@types/scriptjs": "^0.0.2",
45 | "babel-cli": "^6.3.17",
46 | "babel-core": "^6.26.3",
47 | "babel-eslint": "^4.1.6",
48 | "babel-loader": "7.1.5",
49 | "babel-plugin-transform-decorators-legacy": "^1.3.4",
50 | "babel-preset-es2015": "^6.3.13",
51 | "babel-preset-react": "^6.3.13",
52 | "babel-preset-react-native": "^1.9.0",
53 | "babel-preset-stage-1": "^6.3.13",
54 | "babel-runtime": "^6.3.19",
55 | "codemirror": "^5.54.0",
56 | "codesandbox": "^2.2.1",
57 | "css-loader": "^3.5.3",
58 | "diff": "^4.0.1",
59 | "eslint": "^1.10.3",
60 | "eslint-config-standard": "^4.4.0",
61 | "eslint-config-standard-react": "^1.2.1",
62 | "eslint-plugin-react": "^3.13.1",
63 | "eslint-plugin-standard": "^1.3.1",
64 | "file-loader": "^6.0.0",
65 | "gh-pages": "^2.2.0",
66 | "html-webpack-plugin": "^4.3.0",
67 | "inline-style-prefixer": "^6.0.0",
68 | "metro-react-native-babel-preset": "^0.59.0",
69 | "packly": "^0.0.1",
70 | "prettier": "^2.0.5",
71 | "prop-types": "^15.7.2",
72 | "raw-loader": "^4.0.1",
73 | "react": "16.8.0",
74 | "react-dom": "16.8.0",
75 | "react-inspector": "^5.0.1",
76 | "react-loadable": "^5.5.0",
77 | "react-native-web": "0.11.4",
78 | "regenerator-runtime": "^0.9.5",
79 | "screenfull": "^4.2.0",
80 | "scriptjs": "^2.5.8",
81 | "snarkdown": "^1.2.2",
82 | "style-loader": "^1.2.1",
83 | "ts-loader": "^8.0.3",
84 | "typescript": "^3.9.3",
85 | "webpack": "^4.43.0",
86 | "webpack-cli": "^3.3.11",
87 | "webpack-dev-server": "^3.11.0",
88 | "webpack-merge": "^4.2.2",
89 | "worker-loader": "^3.0.2"
90 | },
91 | "dependencies": {},
92 | "prettier": {
93 | "singleQuote": true,
94 | "semi": false
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/babel-worker.js:
--------------------------------------------------------------------------------
1 | import * as Babel from '@babel/core'
2 |
3 | onmessage = function (event) {
4 | const {
5 | id,
6 | payload: {
7 | code: value,
8 | filename,
9 | options: {
10 | instrumentExpressionStatements,
11 | maxLoopIterations,
12 | ...options
13 | },
14 | },
15 | } = event.data
16 |
17 | let output
18 |
19 | try {
20 | const presets = [
21 | require('metro-react-native-babel-preset').getPreset(value, {
22 | enableBabelRuntime: false,
23 | }),
24 | ]
25 |
26 | const plugins = [
27 | ...(maxLoopIterations > 0
28 | ? [
29 | [
30 | require('./utils/BabelInfiniteLoopPlugin'),
31 | { maxIterations: maxLoopIterations },
32 | ],
33 | ]
34 | : []),
35 | ...(instrumentExpressionStatements
36 | ? [require('./utils/BabelExpressionLogPlugin')]
37 | : []),
38 | require('./utils/BabelConsolePlugin'),
39 | [
40 | require('@babel/plugin-transform-typescript'),
41 | {
42 | isTSX: true,
43 | allowNamespaces: true,
44 | },
45 | ],
46 | ]
47 |
48 | const code = Babel.transform(value, {
49 | presets,
50 | plugins,
51 | filename,
52 | ...options,
53 | }).code
54 |
55 | output = {
56 | id,
57 | payload: {
58 | filename,
59 | type: 'code',
60 | code,
61 | },
62 | }
63 | } catch (e) {
64 | output = {
65 | id,
66 | payload: {
67 | filename,
68 | type: 'error',
69 | error: {
70 | message: e.message.replace('unknown', e.name),
71 | },
72 | },
73 | }
74 | }
75 |
76 | postMessage(output)
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/embed/Playground.tsx:
--------------------------------------------------------------------------------
1 | import React, { CSSProperties, memo, useMemo } from 'react'
2 | import type { PublicOptions } from '../../utils/options'
3 |
4 | declare global {
5 | // Defined in webpack config
6 | const VERSION: string
7 | }
8 |
9 | const WEB_PLAYER_URL = `https://unpkg.com/javascript-playgrounds@${VERSION}/public/index.html`
10 |
11 | const styles = {
12 | iframe: {
13 | width: '100%',
14 | height: '100%',
15 | },
16 | }
17 |
18 | interface OwnProps {
19 | style?: CSSProperties
20 | className?: string
21 | baseURL?: string
22 | }
23 |
24 | export type PlaygroundProps = OwnProps & PublicOptions
25 |
26 | /**
27 | * A React component wrapper for the embeddable iframe player. This ensures
28 | * properties are passed and encoded correctly.
29 | *
30 | * Most props are passed directly through to the player; props passed into the
31 | * player can't be changed after the initial render. Other props can be updated
32 | * normally.
33 | */
34 | export default memo(function Playground({
35 | style,
36 | className,
37 | baseURL = WEB_PLAYER_URL,
38 | ...rest
39 | }: PlaygroundProps) {
40 | // If the baseURL changes, set a new src.
41 | // We don't refresh the player if other props change.
42 | const src = useMemo(
43 | () => `${baseURL}#data=${encodeURIComponent(JSON.stringify(rest))}`,
44 | [baseURL]
45 | )
46 |
47 | return (
48 |
49 |
50 |
51 | )
52 | },
53 | propsAreEqual)
54 |
55 | function propsAreEqual(
56 | prevProps: PlaygroundProps,
57 | nextProps: PlaygroundProps
58 | ): boolean {
59 | return (
60 | prevProps.style === nextProps.style &&
61 | prevProps.className === nextProps.className &&
62 | prevProps.baseURL === nextProps.baseURL
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/src/components/player/ConsoleProxy.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MessageCallback,
3 | ConsoleCommand,
4 | Message,
5 | SourceLocation,
6 | LogVisibility,
7 | } from '../../types/Messages'
8 |
9 | export type ConsoleProxy = Console & { _rnwp_log: Console['log'] }
10 |
11 | function attachConsoleMethodsToProxy(self: { console: Console }) {
12 | // I don't think this can fail, but the console object can be strange...
13 | // If it fails, we won't proxy all the methods (which is likely fine)
14 | try {
15 | for (let key in self.console) {
16 | let f = (self.console as any)[key]
17 |
18 | if (typeof f === 'function') {
19 | ;(consoleProxy as any)[key] = f.bind(self.console)
20 | }
21 | }
22 | } catch (e) {}
23 | }
24 |
25 | const consoleProxy = {} as ConsoleProxy
26 |
27 | attachConsoleMethodsToProxy(window)
28 |
29 | let consoleMessageIndex = 0
30 |
31 | const nextMessageId = () => `${+new Date()}-${++consoleMessageIndex}`
32 |
33 | const consoleLogCommon = (
34 | callback: MessageCallback,
35 | id: string,
36 | codeVersion: number,
37 | location: SourceLocation,
38 | visibility: LogVisibility,
39 | ...logs: unknown[]
40 | ) => {
41 | if (visibility !== 'hidden') {
42 | console.log(...logs)
43 | }
44 |
45 | const payload: ConsoleCommand = {
46 | id: nextMessageId(),
47 | command: 'log',
48 | data: logs,
49 | location,
50 | visibility,
51 | }
52 |
53 | const message: Message = {
54 | id: id,
55 | codeVersion,
56 | type: 'console',
57 | payload,
58 | }
59 |
60 | callback(message)
61 | }
62 |
63 | export const consoleLogRNWP = (
64 | callback: MessageCallback,
65 | id: string,
66 | codeVersion: number,
67 | file: string,
68 | line: number,
69 | column: number,
70 | visibility: 'visible' | 'hidden',
71 | ...logs: unknown[]
72 | ) => {
73 | const location = { file, line, column }
74 | return consoleLogCommon(
75 | callback,
76 | id,
77 | codeVersion,
78 | location,
79 | visibility,
80 | ...logs
81 | )
82 | }
83 |
84 | export const consoleLog = (
85 | callback: MessageCallback,
86 | id: string,
87 | codeVersion: number,
88 | visibility: 'visible' | 'hidden',
89 | ...args: unknown[]
90 | ) => {
91 | return consoleLogCommon(
92 | callback,
93 | id,
94 | codeVersion,
95 | { file: '', line: 0, column: 0 },
96 | visibility,
97 | ...args
98 | )
99 | }
100 |
101 | export const consoleClear = (
102 | callback: MessageCallback,
103 | id: string,
104 | codeVersion: number
105 | ) => {
106 | console.clear()
107 |
108 | const payload: ConsoleCommand = {
109 | id: nextMessageId(),
110 | command: 'clear',
111 | }
112 |
113 | const message: Message = {
114 | id: id,
115 | codeVersion,
116 | type: 'console',
117 | payload,
118 | }
119 |
120 | callback(message)
121 | }
122 |
123 | export default consoleProxy
124 |
--------------------------------------------------------------------------------
/src/components/player/VendorComponents.ts:
--------------------------------------------------------------------------------
1 | import $scriptjs from 'scriptjs'
2 |
3 | import * as Networking from '../../utils/Networking'
4 |
5 | // Stubs for registering and getting vendor components
6 | const externalModules: Record = {}
7 | const cjsModules: Record = {}
8 |
9 | // Allow for keypaths for use in namespacing (Org.Component.Blah)
10 | const getObjectFromKeyPath = (data: any, keyPath: string): unknown => {
11 | return keyPath
12 | .split('.')
13 | .reduce((prev: any, curr: string) => prev[curr], data)
14 | }
15 |
16 | export type ExternalModuleShorthand = string
17 | export type ExternalModuleDescription = {
18 | name: string
19 | url: string
20 | globalName?: string
21 | }
22 | export type ExternalModule = ExternalModuleShorthand | ExternalModuleDescription
23 |
24 | // Currently there are two kinds of components:
25 | // - "externals", which use register/get. These store the *actual value*
26 | // - "modules", which use define/require. These store *just the code* and must
27 | // be executed in the module wrapper before use.
28 | // TODO figure out how to merge these
29 | export default class VendorComponents {
30 | static get modules() {
31 | return cjsModules
32 | }
33 |
34 | // Register an external
35 | // name: name used in import/require
36 | // value: external to resolve
37 | static register(name: string, value: unknown) {
38 | externalModules[name] = value
39 | }
40 |
41 | // Get an external by name
42 | static get(name: string) {
43 | return externalModules[name]
44 | }
45 |
46 | // Register a module
47 | // name: name used in import/require
48 | // code: module code to execute
49 | static define(name: string, code: string) {
50 | cjsModules[name] = code
51 | }
52 |
53 | // Get a module by name
54 | static require(name: string) {
55 | return cjsModules[name]
56 | }
57 |
58 | static loadModules(modules: ExternalModuleDescription[]) {
59 | return Promise.all(
60 | modules.map(async ({ name, url }) => {
61 | const text = await Networking.get(url)
62 |
63 | VendorComponents.define(name, text)
64 | })
65 | )
66 | }
67 |
68 | static loadExternals(
69 | externals: (ExternalModuleDescription & { globalName: string })[]
70 | ) {
71 | return new Promise((resolve) => {
72 | if (externals.length === 0) {
73 | resolve()
74 | return
75 | }
76 |
77 | const urls = externals.map((vc) => vc.url)
78 |
79 | $scriptjs(urls, () => {
80 | externals.forEach(({ name, globalName }) => {
81 | // Inject into vendor components
82 | VendorComponents.register(
83 | name,
84 | getObjectFromKeyPath(window, globalName)
85 | )
86 | })
87 | resolve()
88 | })
89 | })
90 | }
91 |
92 | static normalizeExternalModule(
93 | component: ExternalModule
94 | ): ExternalModuleDescription {
95 | return typeof component === 'string'
96 | ? {
97 | name: component,
98 | url: `https://unpkg.com/${component}`,
99 | }
100 | : component
101 | }
102 |
103 | // Load components from urls
104 | static load(components: ExternalModuleDescription[]): Promise {
105 | const modules = components.filter((vc) => !vc.globalName)
106 | const externals = components.filter(
107 | (vc) => !!vc.globalName
108 | ) as (ExternalModuleDescription & { globalName: string })[]
109 |
110 | return Promise.all([
111 | this.loadModules(modules),
112 | this.loadExternals(externals),
113 | ]).then(() => {})
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/components/workspace/About.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useMemo } from 'react'
2 | import snarkdown from 'snarkdown'
3 |
4 | interface Props {
5 | text: string
6 | }
7 |
8 | export default memo(function About({ text }: Props) {
9 | const markdownContent = useMemo(() => (text ? snarkdown(text) : ''), [text])
10 |
11 | return (
12 |
25 | )
26 | })
27 |
--------------------------------------------------------------------------------
/src/components/workspace/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import screenfull from 'screenfull'
3 | import diff, { DiffRange } from '../../utils/Diff'
4 | import { InternalOptions, WorkspaceStep } from '../../utils/options'
5 | import { prefix } from '../../utils/Styles'
6 | import Workspace, { Props as WorkspaceProps } from './Workspace'
7 |
8 | const style = prefix({
9 | flex: '1 1 auto',
10 | display: 'flex',
11 | alignItems: 'stretch',
12 | minWidth: 0,
13 | minHeight: 0,
14 | overflow: 'hidden',
15 | })
16 |
17 | export type WorkspaceDiff = {
18 | type: 'added' | 'changed'
19 | ranges: DiffRange[]
20 | }
21 |
22 | function workspacesStepDiff(
23 | targetStep: WorkspaceStep,
24 | sourceStep: WorkspaceStep
25 | ): Record {
26 | const {
27 | workspace: { files: sourceFiles },
28 | } = sourceStep
29 | const {
30 | workspace: { files: targetFiles },
31 | } = targetStep
32 |
33 | const result: Record = {}
34 |
35 | Object.keys(targetFiles).forEach((filename: string) => {
36 | const exists = filename in sourceFiles
37 | const source = sourceFiles[filename] ?? ''
38 | const lineDiff = diff(source, targetFiles[filename])
39 |
40 | result[filename] = {
41 | type: exists ? 'changed' : 'added',
42 | ranges: lineDiff.added,
43 | }
44 | })
45 |
46 | return result
47 | }
48 |
49 | type Props = Omit & {
50 | onChange: (files: Record) => void
51 | }
52 |
53 | export default function App({
54 | environmentName,
55 | title,
56 | files,
57 | entry,
58 | initialTab,
59 | strings,
60 | styles,
61 | sharedEnvironment,
62 | fullscreen,
63 | responsivePaneSets,
64 | playground,
65 | workspaces,
66 | typescript,
67 | detectedModules,
68 | registerBundledModules,
69 | compiler,
70 | onChange,
71 | }: Props) {
72 | const [activeStepIndex, setActiveStepIndex] = useState(0)
73 |
74 | const diff: WorkspaceProps['diff'] =
75 | workspaces.length > 0 && activeStepIndex > 0
76 | ? workspacesStepDiff(
77 | workspaces[activeStepIndex],
78 | workspaces[activeStepIndex - 1]
79 | )
80 | : {}
81 |
82 | return (
83 |
84 | 0
108 | ? workspaces[activeStepIndex].workspace
109 | : {})}
110 | />
111 |
112 | )
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/workspace/Button.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent, CSSProperties } from 'react'
2 | import { prefixObject } from '../../utils/Styles'
3 |
4 | const colors = {
5 | normal: '#BBB',
6 | error: '#C92C2C',
7 | inverse: 'white',
8 | }
9 |
10 | const baseStyles: Record = {
11 | container: {
12 | flex: '0 0 auto',
13 | display: 'flex',
14 | flexDirection: 'row',
15 | justifyContent: 'center',
16 | alignItems: 'center',
17 | borderWidth: 1,
18 | borderStyle: 'solid',
19 | borderRadius: 3,
20 | cursor: 'pointer',
21 | transition: 'border-color 0.2s',
22 | },
23 | text: {
24 | fontSize: 13,
25 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
26 | padding: '6px 8px',
27 | fontWeight: 'bold',
28 | textOverflow: 'ellipsis',
29 | overflow: 'hidden',
30 | whiteSpace: 'nowrap',
31 | transition: 'color 0.2s',
32 | userSelect: 'none',
33 | },
34 | }
35 |
36 | const styles: Record = {}
37 | const variants: ('normal' | 'error')[] = ['normal', 'error']
38 |
39 | // Generate a style for all variants: normal & error, base & active
40 | variants.forEach((variant) => {
41 | const color = colors[variant]
42 | styles[variant] = {
43 | base: prefixObject({
44 | container: { ...baseStyles.container, borderColor: color },
45 | text: { ...baseStyles.text, color: color },
46 | }),
47 | active: prefixObject({
48 | container: {
49 | ...baseStyles.container,
50 | backgroundColor: color,
51 | borderColor: color,
52 | },
53 | text: { ...baseStyles.text, color: 'white' },
54 | }),
55 | }
56 | })
57 |
58 | interface Props {
59 | active: boolean
60 | inverse: boolean
61 | isError: boolean
62 | onClick: () => void
63 | onChange: (active: boolean) => void
64 | containerStyle?: CSSProperties
65 | textStyle?: CSSProperties
66 | }
67 |
68 | interface State {
69 | hover: boolean
70 | }
71 |
72 | export default class extends PureComponent {
73 | static defaultProps = {
74 | active: false,
75 | inverse: false,
76 | isError: false,
77 | onClick: () => {},
78 | onChange: () => {},
79 | }
80 |
81 | state = {
82 | hover: false,
83 | }
84 |
85 | render() {
86 | const { children, isError, active, inverse, onChange, onClick } = this.props
87 | const { hover } = this.state
88 | const hoverOpacity = hover ? 0.85 : 1
89 |
90 | let currentStyles =
91 | styles[isError ? 'error' : 'normal'][
92 | active !== inverse ? 'active' : 'base'
93 | ]
94 | const containerStyle = {
95 | ...currentStyles.container,
96 | opacity: hoverOpacity,
97 | ...this.props.containerStyle,
98 | }
99 | const textStyle = this.props.textStyle
100 | ? { ...currentStyles.text, ...this.props.textStyle }
101 | : currentStyles.text
102 |
103 | return (
104 |
105 |
this.setState({ hover: true })}
108 | onMouseLeave={() => this.setState({ hover: false })}
109 | onClick={() => {
110 | onClick()
111 | onChange(!active)
112 | }}
113 | >
114 | {children}
115 |
116 |
117 | )
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/components/workspace/CodeSandboxButton.tsx:
--------------------------------------------------------------------------------
1 | import { getParameters } from 'codesandbox/lib/api/define'
2 | import React, { CSSProperties, memo, useMemo } from 'react'
3 | import { useOptions } from '../../contexts/OptionsContext'
4 | import { entries, fromEntries } from '../../utils/Object'
5 | import { prefixObject } from '../../utils/Styles'
6 | import HeaderLink from './HeaderLink'
7 |
8 | const styles = prefixObject({
9 | form: {
10 | display: 'flex',
11 | },
12 | })
13 |
14 | const scriptTargetMap: Record = {
15 | 0: 'ES3',
16 | 1: 'ES5',
17 | 2: 'ES2015',
18 | 3: 'ES2016',
19 | 4: 'ES2017',
20 | 5: 'ES2018',
21 | 6: 'ES2019',
22 | 7: 'ES2020',
23 | 99: 'ESNext',
24 | 100: 'JSON',
25 | }
26 |
27 | const moduleKindMap: Record = {
28 | 0: 'None',
29 | 1: 'CommonJS',
30 | 2: 'AMD',
31 | 3: 'UMD',
32 | 4: 'System',
33 | 5: 'ES2015',
34 | 6: 'ES2020',
35 | 99: 'ESNext',
36 | }
37 |
38 | const jsxEmitMap: Record = {
39 | 0: 'none',
40 | 1: 'preserve',
41 | 2: 'react',
42 | 3: 'react-native',
43 | }
44 |
45 | interface Props {
46 | files: Record
47 | textStyle?: CSSProperties
48 | children?: React.ReactNode
49 | }
50 |
51 | export const CodeSandboxButton = memo(function CodeSandboxButton({
52 | files,
53 | textStyle,
54 | children,
55 | }: Props) {
56 | const internalOptions = useOptions()
57 |
58 | const parameters = useMemo(() => {
59 | const { typescript, title, initialTab: main } = internalOptions
60 | const compilerOptions = typescript.compilerOptions || {}
61 |
62 | const allFiles = {
63 | ...files,
64 | ...(typescript.enabled && {
65 | 'tsconfig.json': JSON.stringify(
66 | {
67 | compilerOptions: {
68 | ...compilerOptions,
69 | ...('target' in compilerOptions && {
70 | target: scriptTargetMap[compilerOptions.target as number],
71 | }),
72 | ...('module' in compilerOptions && {
73 | module: moduleKindMap[compilerOptions.module as number],
74 | }),
75 | ...('jsx' in compilerOptions && {
76 | jsx: jsxEmitMap[compilerOptions.jsx as number],
77 | }),
78 | lib: (compilerOptions.lib || typescript.libs || [])
79 | .map((name) => (name.startsWith('lib.') ? name.slice(4) : name))
80 | .filter((name) => name !== 'lib'),
81 | },
82 | },
83 | null,
84 | 2
85 | ),
86 | }),
87 | }
88 |
89 | return getParameters({
90 | files: {
91 | 'package.json': {
92 | isBinary: false,
93 | content: {
94 | name: title,
95 | version: '1.0.0',
96 | main,
97 | scripts: {
98 | start: `parcel ${main} --open`,
99 | build: `parcel build ${main}`,
100 | },
101 | dependencies: {},
102 | devDependencies: {
103 | 'parcel-bundler': '^1.6.1',
104 | },
105 | } as any,
106 | },
107 | ...fromEntries(
108 | entries(allFiles).map(([name, code]) => [
109 | name,
110 | { isBinary: false, content: code },
111 | ])
112 | ),
113 | },
114 | })
115 | }, [files, internalOptions])
116 |
117 | return (
118 |
133 | )
134 | })
135 |
--------------------------------------------------------------------------------
/src/components/workspace/Console.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent, CSSProperties, memo, createRef } from 'react'
2 | import { prefix, prefixObject } from '../../utils/Styles'
3 | import { MultiInspector } from './Inspector'
4 | import { LogCommand, SourceLocation } from '../../types/Messages'
5 |
6 | const styles = prefixObject({
7 | overlay: {
8 | position: 'absolute',
9 | zIndex: 100,
10 | overflow: 'auto',
11 | boxSizing: 'border-box',
12 | padding: '4px 0',
13 | left: 0,
14 | right: 0,
15 | bottom: 0,
16 | height: '30%',
17 | borderTop: '1px solid #F8F8F8',
18 | background: 'rgba(255,255,255,0.98)',
19 | },
20 | overlayMaximized: {
21 | position: 'absolute',
22 | zIndex: 100,
23 | overflow: 'auto',
24 | boxSizing: 'border-box',
25 | padding: '4px 0',
26 | left: 0,
27 | right: 0,
28 | bottom: 0,
29 | height: '100%',
30 | background: 'rgba(255,255,255,0.98)',
31 | borderLeft: '4px solid rgba(238,238,238,1)',
32 | },
33 | entryRow: {
34 | display: 'flex',
35 | boxSizing: 'border-box',
36 | boxShadow: '0 -1px 0 0 rgb(240,240,240) inset',
37 | padding: '0 7px',
38 | whiteSpace: 'pre',
39 | },
40 | lineNumberSpacer: {
41 | flex: '1 1 auto',
42 | },
43 | lineNumber: {
44 | fontFamily: 'Menlo, monospace',
45 | fontSize: '13px',
46 | lineHeight: '20px',
47 | color: 'rgb(200,200,200)',
48 | textDecoration: 'underline',
49 | },
50 | })
51 |
52 | interface Props {
53 | maximize: boolean
54 | showFileName: boolean
55 | showLineNumber: boolean
56 | renderReactElements: boolean
57 | logs: LogCommand[]
58 | style?: CSSProperties
59 | rowStyle?: CSSProperties
60 | }
61 |
62 | const LineNumber = memo(
63 | ({
64 | showFileName,
65 | location,
66 | }: {
67 | showFileName: boolean
68 | location: SourceLocation
69 | }) => {
70 | const string = showFileName
71 | ? `${location.file}:${location.line}`
72 | : `:${location.line}`
73 |
74 | return (
75 | <>
76 |
77 | {string}
78 | >
79 | )
80 | }
81 | )
82 |
83 | export default class extends PureComponent {
84 | static defaultProps = {
85 | maximize: false,
86 | showFileName: false,
87 | showLineNumber: true,
88 | logs: [],
89 | }
90 |
91 | container = createRef()
92 |
93 | getComputedStyle = () => {
94 | const { style, maximize } = this.props
95 | const defaultStyle = maximize ? styles.overlayMaximized : styles.overlay
96 |
97 | return style ? prefix({ ...defaultStyle, ...style }) : defaultStyle
98 | }
99 |
100 | getComputedRowStyle = () => {
101 | const { rowStyle } = this.props
102 | const defaultStyle = styles.entryRow
103 |
104 | return rowStyle ? prefix({ ...defaultStyle, ...rowStyle }) : defaultStyle
105 | }
106 |
107 | componentDidMount() {
108 | if (!this.container.current) return
109 |
110 | const { clientHeight, scrollHeight } = this.container.current
111 | const maxScrollTop = scrollHeight - clientHeight
112 |
113 | this.container.current.scrollTop = maxScrollTop > 0 ? maxScrollTop : 0
114 | }
115 |
116 | componentDidUpdate() {
117 | if (!this.container.current) return
118 |
119 | const { clientHeight, scrollHeight, scrollTop } = this.container.current
120 | const maxScrollTop = scrollHeight - clientHeight
121 |
122 | // If we're within one clientHeight of the bottom, scroll to bottom
123 | if (maxScrollTop - clientHeight < scrollTop) {
124 | this.container.current.scrollTop = maxScrollTop > 0 ? maxScrollTop : 0
125 | }
126 | }
127 |
128 | renderEntry = (entry: LogCommand) => {
129 | if (entry.visibility === 'hidden') return
130 |
131 | const { renderReactElements, showFileName } = this.props
132 |
133 | const lineNumber =
134 | this.props.showLineNumber && entry.location ? (
135 |
136 | ) : null
137 |
138 | return (
139 |
140 |
145 | {lineNumber}
146 |
147 | )
148 | }
149 |
150 | render() {
151 | const { logs } = this.props
152 |
153 | return (
154 |
155 | {logs.map(this.renderEntry)}
156 |
157 | )
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/components/workspace/Header.tsx:
--------------------------------------------------------------------------------
1 | import React, { CSSProperties, ReactNode, useMemo } from 'react'
2 | import { mergeStyles, prefixObject } from '../../utils/Styles'
3 |
4 | const styles = prefixObject({
5 | container: {
6 | flex: '0 0 40px',
7 | display: 'flex',
8 | flexDirection: 'row',
9 | justifyContent: 'flex-start',
10 | alignItems: 'stretch',
11 | backgroundColor: '#3B3738',
12 | },
13 | text: {
14 | color: '#FFF',
15 | fontSize: 13,
16 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
17 | fontWeight: 'bold',
18 | padding: '0 20px',
19 | display: 'flex',
20 | alignItems: 'center',
21 | },
22 | spacer: {
23 | flex: '1 1 auto',
24 | },
25 | })
26 |
27 | interface Props {
28 | text: string
29 | textStyle?: CSSProperties
30 | headerStyle?: CSSProperties
31 | children?: ReactNode
32 | }
33 |
34 | export default function Header({
35 | text = '',
36 | textStyle,
37 | headerStyle,
38 | children,
39 | }: Props) {
40 | const computedContainerStyle = useMemo(
41 | () => mergeStyles(styles.container, headerStyle),
42 | [headerStyle]
43 | )
44 | const computedTextStyle = useMemo(() => mergeStyles(styles.text, textStyle), [
45 | textStyle,
46 | ])
47 |
48 | return (
49 |
50 |
{text}
51 |
52 | {children}
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/workspace/HeaderLink.tsx:
--------------------------------------------------------------------------------
1 | import React, { ButtonHTMLAttributes, CSSProperties, memo } from 'react'
2 | import { mergeStyles, prefixObject } from '../../utils/Styles'
3 |
4 | const styles = prefixObject({
5 | // Reset button CSS: https://gist.github.com/MoOx/9137295
6 | buttonReset: {
7 | border: 'none',
8 | margin: '0',
9 | padding: '0',
10 | width: 'auto',
11 | overflow: 'visible',
12 | background: 'transparent',
13 | color: 'inherit',
14 | font: 'inherit',
15 | lineHeight: 'normal',
16 | WebkitFontSmoothing: 'inherit',
17 | MozOsxFontSmoothing: 'inherit',
18 | WebkitAppearance: 'none',
19 | },
20 | text: {
21 | color: '#FFF',
22 | fontSize: 13,
23 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
24 | padding: '9px',
25 | display: 'flex',
26 | alignItems: 'center',
27 | cursor: 'pointer',
28 | textDecoration: 'none',
29 | },
30 | })
31 |
32 | type BaseProps = {
33 | textStyle?: CSSProperties
34 | title?: string
35 | children: React.ReactNode
36 | }
37 |
38 | type AnchorProps = BaseProps & {
39 | href: string
40 | }
41 |
42 | type ButtonProps = BaseProps & {
43 | type?: ButtonHTMLAttributes['type']
44 | onClick?: () => void
45 | }
46 |
47 | export default memo(function HeaderLink(props: AnchorProps | ButtonProps) {
48 | const { textStyle, title, children } = props
49 |
50 | const computedTextStyle = mergeStyles(styles.text, textStyle)
51 | const buttonStyle = mergeStyles(styles.buttonReset, styles.text, textStyle)
52 |
53 | if ('href' in props) {
54 | return (
55 |
61 | {children}
62 |
63 | )
64 | } else {
65 | return (
66 |
74 | )
75 | }
76 | })
77 |
--------------------------------------------------------------------------------
/src/components/workspace/Icons.tsx:
--------------------------------------------------------------------------------
1 | // https://github.com/radix-ui/icons
2 |
3 | // MIT License
4 | //
5 | // Copyright (c) 2020 Modulz
6 | //
7 | // Permission is hereby granted, free of charge, to any person obtaining a copy
8 | // of this software and associated documentation files (the "Software"), to deal
9 | // in the Software without restriction, including without limitation the rights
10 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | // copies of the Software, and to permit persons to whom the Software is
12 | // furnished to do so, subject to the following conditions:
13 | //
14 | // The above copyright notice and this permission notice shall be included in all
15 | // copies or substantial portions of the Software.
16 | //
17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 | // SOFTWARE.
24 |
25 | import React, { memo } from 'react'
26 |
27 | type IconProps = React.SVGProps
28 |
29 | const defaultProps: IconProps = {
30 | width: '15',
31 | height: '15',
32 | viewBox: '0 0 15 15',
33 | fill: 'none',
34 | xmlns: 'http://www.w3.org/2000/svg',
35 | }
36 |
37 | export const EnterFullScreenIcon = memo(
38 | ({ color = 'currentColor', ...props }: IconProps) => {
39 | return (
40 |
48 | )
49 | }
50 | )
51 |
52 | export const ExternalLinkIcon = memo(
53 | ({ color = 'currentColor', ...props }: IconProps) => {
54 | return (
55 |
63 | )
64 | }
65 | )
66 |
67 | export const CubeIcon = memo(
68 | ({ color = 'currentColor', ...props }: IconProps) => {
69 | return (
70 |
78 | )
79 | }
80 | )
81 |
82 | export const ReloadIcon = memo(
83 | ({ color = 'currentColor', ...props }: IconProps) => {
84 | return (
85 |
93 | )
94 | }
95 | )
96 |
--------------------------------------------------------------------------------
/src/components/workspace/Inspector.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent, useRef, useEffect } from 'react'
2 | import { prefixObject } from '../../utils/Styles'
3 | import * as DOMCoding from '../../utils/DOMCoding'
4 | import type { InspectorThemeDefinition, InspectorProps } from 'react-inspector'
5 | import inspect from '../../utils/inspect'
6 |
7 | // Types don't match the version we're using. TODO: Upgrade or remove
8 | const Loadable = require('react-loadable')
9 |
10 | const styles = prefixObject({
11 | itemSpacer: {
12 | width: 8,
13 | },
14 | })
15 |
16 | const createInspectorTheme = (base: InspectorThemeDefinition) => ({
17 | ...base,
18 | BASE_FONT_SIZE: '13px',
19 | TREENODE_FONT_SIZE: '13px',
20 | BASE_LINE_HEIGHT: '20px',
21 | TREENODE_LINE_HEIGHT: '20px',
22 | BASE_BACKGROUND_COLOR: 'transparent',
23 | })
24 |
25 | export const Inspector = Loadable({
26 | loader: () =>
27 | import('react-inspector').then(({ default: Inspector, chromeLight }) => {
28 | const theme = createInspectorTheme(chromeLight)
29 |
30 | return (props: InspectorProps) =>
31 | }),
32 | loading: () => null,
33 | })
34 |
35 | interface InlineElementProps {
36 | onMount: (node: HTMLElement) => void
37 | onUnmount: (node: HTMLElement) => void
38 | }
39 |
40 | const InlineElement = ({ onMount, onUnmount }: InlineElementProps) => {
41 | const ref = useRef(null)
42 |
43 | useEffect(() => {
44 | onMount(ref.current!)
45 |
46 | return () => {
47 | onUnmount(ref.current!)
48 | }
49 | }, [])
50 |
51 | return
52 | }
53 |
54 | // https://stackoverflow.com/a/20476546
55 | function isNodeInDOM(o: any) {
56 | return (
57 | typeof o === 'object' &&
58 | o !== null &&
59 | !!(
60 | o.ownerDocument &&
61 | (o.ownerDocument.defaultView || o.ownerDocument.parentWindow).alert
62 | )
63 | )
64 | }
65 |
66 | interface Props {
67 | data: unknown[]
68 | inspector: 'browser' | 'node'
69 | renderReactElements: boolean
70 | expandLevel?: number
71 | }
72 |
73 | export class MultiInspector extends PureComponent {
74 | render() {
75 | const {
76 | data,
77 | renderReactElements,
78 | expandLevel,
79 | inspector: inspectorType,
80 | } = this.props
81 |
82 | const inspectors = []
83 |
84 | for (let i = 0; i < data.length; i++) {
85 | const item = data[i]
86 |
87 | if (isNodeInDOM(item) || item instanceof HTMLElement) {
88 | inspectors.push(
89 | {
92 | node.appendChild(item as HTMLElement)
93 | }}
94 | onUnmount={(node) => {
95 | node.removeChild(item as HTMLElement)
96 | }}
97 | />
98 | )
99 | } else if (
100 | typeof item === 'object' &&
101 | item !== null &&
102 | '__is_react_element' in item
103 | ) {
104 | // Render using the iframe's copy of React
105 | const { element, ReactDOM } = item as {
106 | element: JSX.Element
107 | ReactDOM: typeof import('react-dom')
108 | }
109 |
110 | const key = Math.random().toString()
111 |
112 | if (renderReactElements) {
113 | inspectors.push(
114 | {
117 | ReactDOM.render(element, node)
118 | }}
119 | onUnmount={(node: Element) => {
120 | ReactDOM.unmountComponentAtNode(node)
121 | }}
122 | />
123 | )
124 | } else {
125 | inspectors.push(
126 |
127 | )
128 | }
129 | } else {
130 | switch (inspectorType) {
131 | case 'browser':
132 | inspectors.push(
133 |
134 | )
135 | break
136 | case 'node':
137 | const spans = inspect(item, {
138 | colors: true,
139 | bracketSeparator: '',
140 | depth: expandLevel,
141 | }).map((span, j) => (
142 |
143 | {span.value}
144 |
145 | ))
146 |
147 | inspectors.push({spans})
148 | break
149 | }
150 | }
151 | }
152 |
153 | let content = inspectors
154 | // Add spacers between each item
155 | .reduce((result: JSX.Element[], value, index, list) => {
156 | result.push(value)
157 |
158 | if (index !== list.length - 1) {
159 | result.push()
160 | }
161 |
162 | return result
163 | }, [])
164 |
165 | return content
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/src/components/workspace/Overlay.tsx:
--------------------------------------------------------------------------------
1 | import React, { CSSProperties, memo, ReactNode } from 'react'
2 | import { prefixObject, prefix, mergeStyles } from '../../utils/Styles'
3 |
4 | const baseTextStyle = prefix({
5 | flex: '1',
6 | color: 'rgba(0,0,0,0.5)',
7 | fontSize: 13,
8 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
9 | lineHeight: '20px',
10 | padding: '12px',
11 | })
12 |
13 | let styles = prefixObject({
14 | container: {
15 | flex: '1',
16 | display: 'flex',
17 | flexDirection: 'row',
18 | justifyContent: 'flex-start',
19 | alignItems: 'stretch',
20 | whiteSpace: 'pre-wrap',
21 | },
22 | text: baseTextStyle,
23 | error: mergeStyles(baseTextStyle, {
24 | color: '#C92C2C',
25 | }),
26 | })
27 |
28 | interface Props {
29 | children?: ReactNode
30 | isError: boolean
31 | }
32 |
33 | export default memo(function Overlay({
34 | children = '',
35 | isError = false,
36 | }: Props) {
37 | return (
38 |
41 | )
42 | })
43 |
--------------------------------------------------------------------------------
/src/components/workspace/Phone.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent, ReactNode, memo, useMemo } from 'react'
2 | import PHONES from '../../constants/Phones'
3 | import { prefixObject } from '../../utils/Styles'
4 |
5 | interface Props {
6 | width: number
7 | device: string
8 | scale: number
9 | children?: ReactNode
10 | }
11 |
12 | export default memo(function Phone(
13 | { width, device, scale, children }: Props = {
14 | width: 500,
15 | device: 'ios',
16 | scale: 1,
17 | }
18 | ) {
19 | const {
20 | deviceImageUrl,
21 | deviceImageWidth,
22 | deviceImageHeight,
23 | screenWidth,
24 | screenHeight,
25 | } = PHONES[device]
26 |
27 | const imageScale = Number(width) / deviceImageWidth
28 | const height = imageScale * deviceImageHeight
29 |
30 | const styles = useMemo(() => {
31 | return prefixObject({
32 | container: {
33 | width,
34 | height,
35 | margin: '0 auto',
36 | position: 'relative',
37 | backgroundImage: `url(${deviceImageUrl})`,
38 | backgroundSize: 'cover',
39 | },
40 | screen: {
41 | position: 'absolute',
42 | top: ((deviceImageHeight - screenHeight) / 2) * imageScale,
43 | left: ((deviceImageWidth - screenWidth) / 2) * imageScale,
44 | width: screenWidth * imageScale,
45 | height: screenHeight * imageScale,
46 | backgroundColor: 'white',
47 | },
48 | overlay: {
49 | position: 'absolute',
50 | top: ((deviceImageHeight - screenHeight) / 2) * imageScale,
51 | left: ((deviceImageWidth - screenWidth) / 2) * imageScale,
52 | width: (screenWidth * imageScale) / scale,
53 | height: (screenHeight * imageScale) / scale,
54 | transform: `scale(${scale}, ${scale})`,
55 | transformOrigin: '0 0 0px',
56 | display: 'flex',
57 | },
58 | })
59 | }, [
60 | deviceImageUrl,
61 | deviceImageHeight,
62 | deviceImageWidth,
63 | screenHeight,
64 | screenWidth,
65 | imageScale,
66 | scale,
67 | ])
68 |
69 | return (
70 |
71 |
72 |
{children}
73 |
74 | )
75 | })
76 |
--------------------------------------------------------------------------------
/src/components/workspace/PlayerFrame.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from 'react'
2 | import { ConsoleCommand, Message } from '../../types/Messages'
3 | import * as ExtendedJSON from '../../utils/ExtendedJSON'
4 | import { encode } from '../../utils/queryString'
5 | import { prefixObject } from '../../utils/Styles'
6 | import type { ExternalModule } from '../player/VendorComponents'
7 | import Phone from './Phone'
8 | import { ExternalStyles } from './Workspace'
9 |
10 | const styles = prefixObject({
11 | iframe: {
12 | flex: '1 1 auto',
13 | minWidth: 0,
14 | minHeight: 0,
15 | },
16 | })
17 |
18 | interface Props {
19 | externalStyles: ExternalStyles
20 | environmentName: string
21 | platform: string
22 | width: number
23 | scale: number
24 | assetRoot: string
25 | statusBarHeight: number
26 | statusBarColor: string
27 | sharedEnvironment: boolean
28 | detectedModules: ExternalModule[]
29 | modules: ExternalModule[]
30 | registerBundledModules: boolean
31 | styleSheet: string
32 | css: string
33 | prelude: string
34 | onRun: () => void
35 | onReady: () => void
36 | onConsole: (codeVersion: number, payload: ConsoleCommand) => void
37 | onError: (codeVersion: number, payload: string) => void
38 | }
39 |
40 | interface State {
41 | id: string | null
42 | }
43 |
44 | export default class extends PureComponent {
45 | static defaultProps = {
46 | preset: 'react-native',
47 | platform: 'ios',
48 | width: 210,
49 | scale: 1,
50 | assetRoot: '',
51 | statusBarHeight: 0,
52 | statusBarColor: 'black',
53 | sharedEnvironment: true,
54 | modules: [],
55 | styleSheet: 'reset',
56 | css: '',
57 | prelude: '',
58 | onRun: () => {},
59 | onReady: () => {},
60 | onConsole: () => {},
61 | onError: () => {},
62 | }
63 |
64 | status: string = 'loading'
65 | fileMap?: Record
66 | entry?: string
67 | codeVersion?: number
68 |
69 | state: State = {
70 | id: null,
71 | }
72 |
73 | iframe = React.createRef()
74 |
75 | componentDidMount() {
76 | const { sharedEnvironment } = this.props
77 |
78 | this.setState({
79 | id: Math.random().toString().slice(2),
80 | })
81 |
82 | const handleMessageData = (data: Message) => {
83 | if (data.id !== this.state.id) return
84 |
85 | switch (data.type) {
86 | case 'ready':
87 | this.status = 'ready'
88 | this.props.onReady()
89 | if (this.fileMap) {
90 | this.runApplication(this.fileMap, this.entry!, this.codeVersion!)
91 | this.fileMap = undefined
92 | this.entry = undefined
93 | this.codeVersion = undefined
94 | }
95 | break
96 | case 'error':
97 | this.props.onError(data.codeVersion, data.payload)
98 | break
99 | case 'console':
100 | this.props.onConsole(data.codeVersion, data.payload)
101 | break
102 | }
103 | }
104 |
105 | if (sharedEnvironment) {
106 | window.__message = handleMessageData
107 | }
108 |
109 | window.addEventListener('message', (e) => {
110 | let data: Message
111 | try {
112 | data = ExtendedJSON.parse(e.data) as Message
113 | } catch (err) {
114 | return
115 | }
116 |
117 | handleMessageData(data)
118 | })
119 | }
120 |
121 | runApplication(
122 | fileMap: Record,
123 | entry: string,
124 | codeVersion: number
125 | ) {
126 | this.props.onRun()
127 | switch (this.status) {
128 | case 'loading':
129 | this.fileMap = fileMap
130 | this.entry = entry
131 | this.codeVersion = codeVersion
132 | break
133 | case 'ready':
134 | this.iframe.current!.contentWindow!.postMessage(
135 | { fileMap, entry, codeVersion, source: 'rnwp' },
136 | '*'
137 | )
138 | break
139 | }
140 | }
141 |
142 | reload() {
143 | if (!this.iframe.current) return
144 |
145 | this.iframe.current.contentWindow?.location.reload()
146 | }
147 |
148 | renderFrame = () => {
149 | const {
150 | externalStyles,
151 | environmentName,
152 | assetRoot,
153 | registerBundledModules,
154 | detectedModules,
155 | modules,
156 | styleSheet,
157 | css,
158 | statusBarColor,
159 | statusBarHeight,
160 | sharedEnvironment,
161 | prelude,
162 | } = this.props
163 | const { id } = this.state
164 |
165 | if (!id) return null
166 |
167 | const queryString = encode({
168 | environmentName,
169 | id,
170 | sharedEnvironment,
171 | assetRoot,
172 | detectedModules: JSON.stringify(detectedModules),
173 | modules: JSON.stringify(modules),
174 | registerBundledModules,
175 | styleSheet,
176 | css,
177 | statusBarColor,
178 | statusBarHeight,
179 | prelude,
180 | styles: JSON.stringify({
181 | playerRoot: externalStyles.playerRoot,
182 | playerWrapper: externalStyles.playerWrapper,
183 | playerApp: externalStyles.playerApp,
184 | }),
185 | })
186 |
187 | return (
188 |
194 | )
195 | }
196 |
197 | render() {
198 | const { width, scale, platform } = this.props
199 |
200 | if (platform === 'web') {
201 | return this.renderFrame()
202 | }
203 |
204 | return (
205 |
206 | {this.renderFrame()}
207 |
208 | )
209 | }
210 | }
211 |
--------------------------------------------------------------------------------
/src/components/workspace/PlaygroundPreview.tsx:
--------------------------------------------------------------------------------
1 | import { ResizeObserver } from '@juggle/resize-observer'
2 | import React, { useEffect, useRef, RefObject } from 'react'
3 | import { prefixObject } from '../../utils/Styles'
4 | import { MultiInspector } from './Inspector'
5 | import type { PlaygroundOptions } from './Workspace'
6 |
7 | const styles = prefixObject({
8 | container: {
9 | backgroundColor: 'rgba(0,0,0,0.02)',
10 | border: '1px solid rgba(0,0,0,0.05)',
11 | padding: '4px 8px',
12 | borderRadius: 8,
13 | display: 'inline-block',
14 | flexDirection: 'row',
15 | alignItems: 'stretch',
16 | minWidth: 0,
17 | minHeight: 0,
18 | },
19 | content: {
20 | display: 'flex',
21 | whiteSpace: 'pre',
22 | },
23 | itemSpacer: {
24 | width: 8,
25 | },
26 | })
27 |
28 | function useResizeObserver(ref: RefObject, f: () => void) {
29 | useEffect(() => {
30 | let resizeObserver: ResizeObserver
31 | let mounted = true
32 |
33 | import('@juggle/resize-observer').then(({ ResizeObserver }) => {
34 | if (!mounted) return
35 |
36 | resizeObserver = new ResizeObserver(() => {
37 | f()
38 | })
39 |
40 | if (ref.current) {
41 | resizeObserver.observe(ref.current)
42 | }
43 | })
44 |
45 | return () => {
46 | mounted = false
47 |
48 | if (ref.current && resizeObserver) {
49 | resizeObserver.unobserve(ref.current)
50 | }
51 | }
52 | }, [])
53 | }
54 |
55 | interface Props {
56 | indent: number
57 | data: unknown[]
58 | didResize: () => void
59 | playgroundOptions: PlaygroundOptions
60 | }
61 |
62 | export default function PlaygroundPreview({
63 | indent,
64 | data,
65 | didResize,
66 | playgroundOptions,
67 | }: Props) {
68 | const ref = useRef(null)
69 |
70 | useResizeObserver(ref, didResize)
71 |
72 | return (
73 |
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/src/components/workspace/Spacer.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useMemo } from 'react'
2 | import { prefix, prefixObject } from '../../utils/Styles'
3 |
4 | interface Props {
5 | size?: number
6 | }
7 |
8 | const styles = prefixObject({
9 | flexSpacer: { flex: '1', display: 'block' },
10 | })
11 |
12 | export const VerticalSpacer = memo(({ size }: Props) => {
13 | const style = useMemo(
14 | () =>
15 | prefix(
16 | size === undefined
17 | ? styles.flexSpacer
18 | : { height: `${size}px`, display: 'block' }
19 | ),
20 | [size]
21 | )
22 |
23 | return
24 | })
25 |
26 | export const HorizontalSpacer = memo(({ size }: Props) => {
27 | const style = useMemo(
28 | () =>
29 | prefix(
30 | size === undefined
31 | ? styles.flexSpacer
32 | : { width: `${size}px`, display: 'block' }
33 | ),
34 | [size]
35 | )
36 |
37 | return
38 | })
39 |
--------------------------------------------------------------------------------
/src/components/workspace/Status.tsx:
--------------------------------------------------------------------------------
1 | import React, { CSSProperties, ReactNode, useMemo } from 'react'
2 | import { mergeStyles, prefix, prefixObject } from '../../utils/Styles'
3 |
4 | const baseTextStyle = prefix({
5 | color: '#BBB',
6 | fontSize: 13,
7 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
8 | padding: '0 12px',
9 | fontWeight: 'bold',
10 | textOverflow: 'ellipsis',
11 | overflow: 'hidden',
12 | whiteSpace: 'nowrap',
13 | transition: 'color 0.2s',
14 | lineHeight: '1.2',
15 | })
16 |
17 | const styles = prefixObject({
18 | container: {
19 | flex: '0 0 40px',
20 | display: 'flex',
21 | flexDirection: 'row',
22 | justifyContent: 'space-between',
23 | alignItems: 'center',
24 | backgroundColor: 'white',
25 | borderTop: '1px solid #F7F7F7',
26 | borderLeft: '4px solid rgba(238,238,238,1)',
27 | boxSizing: 'border-box',
28 | paddingRight: 7,
29 | },
30 | text: baseTextStyle,
31 | error: {
32 | ...baseTextStyle,
33 | color: '#C92C2C',
34 | },
35 | })
36 |
37 | interface Props {
38 | text: string
39 | isError: boolean
40 | style?: CSSProperties
41 | textStyle?: CSSProperties
42 | errorTextStyle?: CSSProperties
43 | children?: ReactNode
44 | }
45 |
46 | export default function Status({
47 | text,
48 | isError,
49 | children,
50 | style,
51 | textStyle,
52 | errorTextStyle,
53 | }: Props) {
54 | const computedContainerStyle = useMemo(
55 | () => mergeStyles(styles.container, style),
56 | [style]
57 | )
58 |
59 | const computedTextStyle = useMemo(() => mergeStyles(styles.text, textStyle), [
60 | textStyle,
61 | ])
62 |
63 | const computedErrorTextStyle = useMemo(
64 | () => mergeStyles(styles.error, textStyle, errorTextStyle),
65 | [textStyle, errorTextStyle]
66 | )
67 |
68 | return (
69 |
70 |
71 | {text}
72 |
73 | {children}
74 |
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/workspace/TabContainer.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | CSSProperties,
3 | memo,
4 | ReactNode,
5 | useCallback,
6 | useState,
7 | } from 'react'
8 | import { prefixObject } from '../../utils/Styles'
9 | import Tabs from './Tabs'
10 |
11 | const styles = prefixObject({
12 | container: {
13 | flex: '1',
14 | display: 'flex',
15 | flexDirection: 'column',
16 | alignItems: 'stretch',
17 | minWidth: 0,
18 | minHeight: 0,
19 | },
20 | })
21 |
22 | interface Props {
23 | tabs: T[]
24 | initialTab?: T
25 | onClickTab?: (tab: T) => {}
26 | renderContent: (tab: T, index: number) => ReactNode
27 | getTitle: (a: T) => string
28 | compareTabs: (a: T, b: T) => boolean
29 | renderHiddenContent: boolean
30 | tabStyle?: CSSProperties
31 | textStyle?: CSSProperties
32 | activeTextStyle?: CSSProperties
33 | }
34 |
35 | const defaultGetChanged = () => false
36 |
37 | export default memo(function TabContainer({
38 | initialTab,
39 | tabs,
40 | getTitle,
41 | compareTabs,
42 | tabStyle,
43 | textStyle,
44 | activeTextStyle,
45 | onClickTab,
46 | renderContent,
47 | renderHiddenContent,
48 | }: Props) {
49 | const [activeTab, setActiveTab] = useState(initialTab)
50 |
51 | const onClickTabAndSetActive = useCallback(
52 | (tab) => {
53 | if (onClickTab) {
54 | onClickTab(tab)
55 | }
56 | setActiveTab(tab)
57 | },
58 | [tabs]
59 | )
60 |
61 | return (
62 |
63 |
74 | {tabs.map((tab: T, index: number) => {
75 | if (activeTab && compareTabs(tab, activeTab)) {
76 | return (
77 |
78 | {renderContent(tab, index)}
79 |
80 | )
81 | }
82 |
83 | if (renderHiddenContent) {
84 | return (
85 |
86 | {renderContent(tab, index)}
87 |
88 | )
89 | }
90 |
91 | return null
92 | })}
93 |
94 | )
95 | })
96 |
--------------------------------------------------------------------------------
/src/components/workspace/Tabs.tsx:
--------------------------------------------------------------------------------
1 | import React, { CSSProperties, memo, ReactNode, useMemo } from 'react'
2 | import { prefixObject, mergeStyles, prefix } from '../../utils/Styles'
3 |
4 | const baseTextStyle = prefix({
5 | userSelect: 'none',
6 | color: 'rgba(255,255,255,0.6)',
7 | fontSize: 13,
8 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
9 | lineHeight: '40px',
10 | padding: '0 20px',
11 | cursor: 'pointer',
12 | borderBottomStyle: 'solid',
13 | borderBottomColor: 'rgb(59, 108, 212)',
14 | borderBottomWidth: 0,
15 | transition: 'border-width 0.1s, color 0.1s',
16 | })
17 |
18 | const styles = prefixObject({
19 | container: {
20 | flex: '0 0 40px',
21 | display: 'flex',
22 | flexDirection: 'row',
23 | justifyContent: 'flex-start',
24 | alignItems: 'stretch',
25 | backgroundColor: '#3B3738',
26 | boxShadow: '0 1px 1px rgba(0,0,0,0.2)',
27 | zIndex: 1000,
28 | overflow: 'hidden',
29 | },
30 | text: baseTextStyle,
31 | activeText: mergeStyles(baseTextStyle, {
32 | borderBottomWidth: 3,
33 | color: '#FFF',
34 | }),
35 | changedText: {
36 | color: '#7ABE66',
37 | },
38 | spacer: {
39 | flex: '1 1 auto',
40 | },
41 | })
42 |
43 | interface Props {
44 | tabs: T[]
45 | activeTab?: T
46 | onClickTab: (tab: T) => void
47 | getTitle: (a: T) => string
48 | getChanged: (a: T) => boolean
49 | compareTabs: (a: T, b: T) => boolean
50 | textStyle?: CSSProperties
51 | activeTextStyle?: CSSProperties
52 | changedTextStyle?: CSSProperties
53 | tabStyle?: CSSProperties
54 | children?: ReactNode
55 | }
56 |
57 | interface TabProps {
58 | title: string
59 | style: CSSProperties
60 | onClick: () => void
61 | }
62 |
63 | const Tab = memo(function Tab({ title, style, onClick }: TabProps) {
64 | return (
65 |
66 | {title}
67 |
68 | )
69 | })
70 |
71 | export default memo(function Tabs({
72 | children = undefined,
73 | tabs = [],
74 | getTitle,
75 | tabStyle,
76 | activeTab,
77 | textStyle,
78 | activeTextStyle,
79 | changedTextStyle,
80 | compareTabs,
81 | onClickTab,
82 | getChanged,
83 | }: Props) {
84 | const activeTabIndex = tabs.findIndex(
85 | (tab) => activeTab && compareTabs(tab, activeTab)
86 | )
87 | const clickHandlers = useMemo(
88 | () => tabs.map((tab) => onClickTab.bind(null, tab)),
89 | [tabs, onClickTab]
90 | )
91 |
92 | const containerStyle = useMemo(
93 | () => mergeStyles(styles.container, tabStyle),
94 | [tabStyle]
95 | )
96 | // Pre-compute all tabs styles. The combinations: normal, active, changed, active + changed
97 | const computedTabStyle = useMemo(() => mergeStyles(styles.text, textStyle), [
98 | textStyle,
99 | ])
100 | const computedActiveTabStyle = useMemo(
101 | () => mergeStyles(styles.activeText, textStyle, activeTextStyle),
102 | [textStyle, activeTextStyle]
103 | )
104 | const computedChangedTabStyle = useMemo(
105 | () => mergeStyles(computedTabStyle, styles.changedText, changedTextStyle),
106 | [computedTabStyle, changedTextStyle]
107 | )
108 | const computedChangedActiveTabStyle = useMemo(
109 | () =>
110 | mergeStyles(computedActiveTabStyle, styles.changedText, changedTextStyle),
111 | [computedActiveTabStyle, changedTextStyle]
112 | )
113 |
114 | return (
115 |
116 | {tabs.map((tab, index) => {
117 | const isActive = activeTabIndex === index
118 | const isChanged = getChanged(tab)
119 |
120 | return (
121 |
135 | )
136 | })}
137 |
138 | {children}
139 |
140 | )
141 | })
142 |
--------------------------------------------------------------------------------
/src/components/workspace/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo } from 'react'
2 | import type * as ts from 'typescript'
3 | import { prefixObject } from '../../utils/Styles'
4 |
5 | // TypeScript's SymbolDisplayPartKind
6 | //
7 | // aliasName = 0,
8 | // className = 1,
9 | // enumName = 2,
10 | // fieldName = 3,
11 | // interfaceName = 4,
12 | // keyword = 5,
13 | // lineBreak = 6,
14 | // numericLiteral = 7,
15 | // stringLiteral = 8,
16 | // localName = 9,
17 | // methodName = 10,
18 | // moduleName = 11,
19 | // operator = 12,
20 | // parameterName = 13,
21 | // propertyName = 14,
22 | // punctuation = 15,
23 | // space = 16,
24 | // text = 17,
25 | // typeParameterName = 18,
26 | // enumMemberName = 19,
27 | // functionName = 20,
28 | // regularExpressionLiteral = 21
29 | //
30 | function classNameForKind(kind: string) {
31 | switch (kind) {
32 | case 'keyword':
33 | return 'cm-keyword'
34 | case 'numericLiteral':
35 | return 'cm-number'
36 | case 'stringLiteral':
37 | return 'cm-string'
38 | case 'regularExpressionLiteral':
39 | return 'cm-string2'
40 | default:
41 | return ''
42 | }
43 | }
44 |
45 | const styles = prefixObject({
46 | type: {
47 | display: 'inline-block',
48 | padding: '4px 8px',
49 | fontFamily: "'source-code-pro', Menlo, 'Courier New', Consolas, monospace",
50 | },
51 | documentation: {
52 | display: 'inline-block',
53 | padding: '4px 8px',
54 | color: '#7d8b99',
55 | },
56 | documentationPart: {
57 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
58 | },
59 | divider: {
60 | height: '1px',
61 | backgroundColor: 'rgba(0,0,0,0.2)',
62 | },
63 | })
64 |
65 | interface Props {
66 | type?: ts.SymbolDisplayPart[]
67 | documentation?: ts.SymbolDisplayPart[]
68 | }
69 |
70 | export default memo(function Tooltip({ type = [], documentation = [] }: Props) {
71 | return (
72 | <>
73 |
74 | {type.map(({ text, kind }, index) => (
75 |
76 | {text}
77 |
78 | ))}
79 |
80 | {documentation.length > 0 && }
81 |
82 | {documentation.map(({ text }, index) => (
83 |
84 | {text}
85 |
86 | ))}
87 |
88 | >
89 | )
90 | })
91 |
--------------------------------------------------------------------------------
/src/components/workspace/WorkspacesList.tsx:
--------------------------------------------------------------------------------
1 | import React, { PureComponent, CSSProperties } from 'react'
2 | import snarkdown from 'snarkdown'
3 | import { prefix, prefixObject } from '../../utils/Styles'
4 | import Button from './Button'
5 |
6 | const rawStyles: Record = {
7 | container: {
8 | flex: '1 1 auto',
9 | display: 'flex',
10 | flexDirection: 'column',
11 | minWidth: 0,
12 | minHeight: 0,
13 | backgroundColor: '#FAFAFA',
14 | },
15 | row: {
16 | display: 'flex',
17 | flexDirection: 'column',
18 | alignItems: 'stretch',
19 | boxSizing: 'border-box',
20 | borderLeftStyle: 'solid',
21 | borderLeftColor: 'rgba(238,238,238,1)',
22 | borderLeftWidth: 4,
23 | cursor: 'pointer',
24 | transition: 'border-color 0.1s',
25 | },
26 | rowTitle: {
27 | color: 'rgb(170, 170, 170)',
28 | fontSize: 13,
29 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
30 | display: 'flex',
31 | alignItems: 'center',
32 | transition: 'color 0.1s',
33 | paddingTop: 14,
34 | paddingRight: 14,
35 | paddingBottom: 14,
36 | paddingLeft: 10,
37 | },
38 | description: {
39 | display: 'flex',
40 | flexDirection: 'column',
41 | alignItems: 'flex-start',
42 | boxSizing: 'border-box',
43 | backgroundColor: 'rgba(25, 144, 184, 0.85)',
44 | padding: 14,
45 | },
46 | descriptionText: {
47 | fontSize: 13,
48 | fontFamily: 'proxima-nova, "Helvetica Neue", Helvetica, Arial, sans-serif',
49 | whiteSpace: 'pre-wrap',
50 | color: 'white',
51 | },
52 | buttonWrapper: {
53 | display: 'flex',
54 | flexDirection: 'column',
55 | alignItems: 'flex-start',
56 | paddingTop: 2,
57 | paddingRight: 14,
58 | paddingBottom: 18,
59 | paddingLeft: 14,
60 | backgroundColor: 'rgba(25, 144, 184, 0.85)',
61 | },
62 | buttonContainer: {
63 | backgroundColor: 'white',
64 | border: 'none',
65 | },
66 | buttonText: {
67 | color: 'rgba(0,0,0,0.6)',
68 | fontWeight: 'normal',
69 | },
70 | divider: {
71 | height: 1,
72 | },
73 | }
74 |
75 | rawStyles.activeRow = {
76 | ...rawStyles.row,
77 | borderLeftColor: '#1990B8',
78 | backgroundColor: '#1990B8',
79 | marginBottom: 0,
80 | }
81 |
82 | rawStyles.activeRowTitle = {
83 | ...rawStyles.rowTitle,
84 | color: 'white',
85 | }
86 |
87 | const styles = prefixObject(rawStyles)
88 |
89 | export interface Step {
90 | title: string
91 | description?: string
92 | }
93 |
94 | interface Props {
95 | steps: Step[]
96 | activeStepIndex: number
97 | onChangeActiveStepIndex: (index: number) => void
98 | showNextButton?: boolean
99 | style?: CSSProperties
100 | rowStyle?: CSSProperties
101 | rowStyleActive?: CSSProperties
102 | rowTitleStyle?: CSSProperties
103 | rowTitleStyleActive?: CSSProperties
104 | descriptionStyle?: CSSProperties
105 | descriptionTextStyle?: CSSProperties
106 | buttonTextStyle?: CSSProperties
107 | buttonContainerStyle?: CSSProperties
108 | buttonWrapperStyle?: CSSProperties
109 | dividerStyle?: CSSProperties
110 | }
111 |
112 | function computeActiveStyle(
113 | isActive: boolean,
114 | style: CSSProperties,
115 | activeStyle?: CSSProperties,
116 | externalStyle?: CSSProperties,
117 | externalActiveStyle?: CSSProperties
118 | ) {
119 | if (isActive) {
120 | if (!externalStyle && !externalActiveStyle) return activeStyle
121 |
122 | return {
123 | ...activeStyle,
124 | ...(externalStyle ? prefix(externalStyle) : {}),
125 | ...(externalActiveStyle ? prefix(externalActiveStyle) : {}),
126 | }
127 | } else {
128 | return externalStyle ? { ...style, ...prefix(externalStyle) } : style
129 | }
130 | }
131 |
132 | function computeStyle(style: CSSProperties, externalStyle?: CSSProperties) {
133 | return externalStyle ? { ...style, ...prefix(externalStyle) } : style
134 | }
135 |
136 | export default class WorkspacesList extends PureComponent {
137 | static defaultProps = {
138 | style: null,
139 | rowStyle: null,
140 | rowTitleStyle: null,
141 | descriptionStyle: null,
142 | activeRowStyle: null,
143 | onChangeActiveStepIndex: () => {},
144 | }
145 |
146 | getComputedStyle = () => {
147 | const { style } = this.props
148 | const defaultStyle = styles.container
149 |
150 | return style ? prefix({ ...defaultStyle, ...style }) : defaultStyle
151 | }
152 |
153 | getComputedRowStyle = (isActive: boolean) => {
154 | const { rowStyle, rowStyleActive } = this.props
155 |
156 | return computeActiveStyle(
157 | isActive,
158 | styles.row,
159 | styles.activeRow,
160 | rowStyle,
161 | rowStyleActive
162 | )
163 | }
164 |
165 | getComputedRowTitleStyle = (isActive: boolean) => {
166 | const { rowTitleStyle, rowTitleStyleActive } = this.props
167 |
168 | return computeActiveStyle(
169 | isActive,
170 | styles.rowTitle,
171 | styles.activeRowTitle,
172 | rowTitleStyle,
173 | rowTitleStyleActive
174 | )
175 | }
176 |
177 | getComputedDescriptionStyle = () => {
178 | const { descriptionStyle } = this.props
179 |
180 | return computeStyle(styles.description, descriptionStyle)
181 | }
182 |
183 | getComputedDescriptionTextStyle = () => {
184 | const { descriptionTextStyle } = this.props
185 |
186 | return computeStyle(styles.descriptionText, descriptionTextStyle)
187 | }
188 |
189 | getComputedButtonTextStyle = () => {
190 | const { buttonTextStyle } = this.props
191 |
192 | return computeStyle(styles.buttonText, buttonTextStyle)
193 | }
194 |
195 | getComputedButtonContainerStyle = () => {
196 | const { buttonContainerStyle } = this.props
197 |
198 | return computeStyle(styles.buttonContainer, buttonContainerStyle)
199 | }
200 |
201 | getComputedButtonWrapperStyle = () => {
202 | const { buttonWrapperStyle } = this.props
203 |
204 | return computeStyle(styles.buttonWrapper, buttonWrapperStyle)
205 | }
206 |
207 | getComputedDividerStyle = () => {
208 | const { dividerStyle } = this.props
209 |
210 | return computeStyle(styles.divider, dividerStyle)
211 | }
212 |
213 | renderStep = (step: Step, index: number, list: Step[]) => {
214 | const {
215 | activeStepIndex,
216 | onChangeActiveStepIndex,
217 | showNextButton = true,
218 | } = this.props
219 | const { title, description } = step
220 |
221 | const isActive = index === activeStepIndex
222 |
223 | return (
224 |
225 | onChangeActiveStepIndex(index)}
228 | >
229 |
{title}
230 |
231 | {description && isActive && (
232 |
239 | )}
240 | {showNextButton && isActive && index !== list.length - 1 && (
241 |
242 |
250 |
251 | )}
252 |
253 |
254 | )
255 | }
256 |
257 | render() {
258 | const { steps } = this.props
259 |
260 | return (
261 | {steps.map(this.renderStep)}
262 | )
263 | }
264 | }
265 |
--------------------------------------------------------------------------------
/src/components/workspace/panes/ConsolePane.tsx:
--------------------------------------------------------------------------------
1 | import React, { CSSProperties, memo } from 'react'
2 | import { LogCommand } from '../../../types/Messages'
3 | import {
4 | columnStyle,
5 | mergeStyles,
6 | prefixObject,
7 | rowStyle,
8 | } from '../../../utils/Styles'
9 | import Console from '../Console'
10 | import Header from '../Header'
11 | import { ConsolePaneOptions } from '../../../utils/Panes'
12 | import { ExternalStyles } from '../Workspace'
13 |
14 | const styles = prefixObject({
15 | consolePane: columnStyle,
16 | column: columnStyle,
17 | row: rowStyle,
18 | })
19 |
20 | interface Props {
21 | options: ConsolePaneOptions
22 | externalStyles: ExternalStyles
23 | files: Record
24 | logs: LogCommand[]
25 | }
26 |
27 | export default memo(function ConsolePane({
28 | options,
29 | externalStyles,
30 | files,
31 | logs,
32 | }: Props) {
33 | const style = mergeStyles(
34 | styles.consolePane,
35 | externalStyles.consolePane,
36 | options.style
37 | )
38 |
39 | return (
40 |
41 | {options.title && (
42 |
47 | )}
48 |
49 |
50 | 1 && options.showFileName}
55 | showLineNumber={options.showLineNumber}
56 | logs={logs}
57 | renderReactElements={options.renderReactElements}
58 | />
59 |
60 |
61 |
62 | )
63 | })
64 |
--------------------------------------------------------------------------------
/src/components/workspace/panes/EditorPane.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useState } from 'react'
2 | import screenfull from 'screenfull'
3 | import { LogCommand } from '../../../types/Messages'
4 | import { EditorPaneOptions } from '../../../utils/Panes'
5 | import { columnStyle, mergeStyles, prefixObject, rowStyle } from '../../../utils/Styles'
6 | import {
7 | compareTabs,
8 | getTabChanged,
9 | getTabTitle,
10 | Tab,
11 | } from '../../../utils/Tab'
12 | import About from '../About'
13 | import Button from '../Button'
14 | import Editor, { Props as EditorProps } from '../Editor'
15 | import HeaderLink from '../HeaderLink'
16 | import Header from '../Header'
17 | import Overlay from '../Overlay'
18 | import Status from '../Status'
19 | import Tabs from '../Tabs'
20 | import { PlaygroundOptions, PublicError, ExternalStyles } from '../Workspace'
21 | import type { WorkspaceDiff } from '../App'
22 | import { TypeScriptOptions, UserInterfaceStrings } from '../../../utils/options'
23 | import { useOptions } from '../../../contexts/OptionsContext'
24 | import { CodeSandboxButton } from '../CodeSandboxButton'
25 | import { CubeIcon, EnterFullScreenIcon, ExternalLinkIcon } from '../Icons'
26 | import { HorizontalSpacer } from '../Spacer'
27 | import WorkspacesList from '../WorkspacesList'
28 |
29 | const toggleFullscreen = () => (screenfull as any).toggle()
30 |
31 | const styles = prefixObject({
32 | editorPane: columnStyle,
33 | overlayContainer: {
34 | position: 'relative',
35 | flex: 0,
36 | height: 0,
37 | alignItems: 'stretch',
38 | },
39 | overlay: {
40 | position: 'absolute',
41 | bottom: 0,
42 | background: 'rgba(255,255,255,0.95)',
43 | zIndex: 100,
44 | left: 4,
45 | right: 0,
46 | borderTop: '1px solid #F8F8F8',
47 | display: 'flex',
48 | alignItems: 'stretch',
49 | overflow: 'auto',
50 | maxHeight: 300,
51 | },
52 | boldMessage: {
53 | fontWeight: 'bold',
54 | },
55 | codeMessage: {
56 | display: 'block',
57 | fontFamily: `'source-code-pro', Menlo, 'Courier New', Consolas, monospace`,
58 | borderRadius: 4,
59 | padding: '4px 8px',
60 | backgroundColor: 'rgba(0,0,0,0.02)',
61 | border: '1px solid rgba(0,0,0,0.05)',
62 | },
63 | sidebar: mergeStyles(columnStyle, {
64 | flex: '0 0 220px',
65 | overflowX: 'hidden',
66 | overflowY: 'auto',
67 | }),
68 | })
69 |
70 | interface Props {
71 | options: EditorPaneOptions
72 | externalStyles: ExternalStyles
73 | ready: boolean
74 | files: Record
75 | strings: UserInterfaceStrings
76 | logs: LogCommand[]
77 | fullscreen: boolean
78 | activeStepIndex: number
79 | diff: Record
80 | playgroundOptions: PlaygroundOptions
81 | typescriptOptions: TypeScriptOptions
82 | compilerError?: PublicError
83 | runtimeError?: PublicError
84 | activeFile: string
85 | activeFileTab?: Tab
86 | fileTabs: Tab[]
87 | onChange: EditorProps['onChange']
88 | getTypeInfo: EditorProps['getTypeInfo']
89 | onClickTab: (tab: Tab) => void
90 | }
91 |
92 | export default memo(function EditorPane({
93 | files,
94 | externalStyles,
95 | ready,
96 | strings,
97 | fullscreen,
98 | activeStepIndex,
99 | diff,
100 | playgroundOptions,
101 | typescriptOptions,
102 | compilerError,
103 | runtimeError,
104 | activeFile,
105 | activeFileTab,
106 | fileTabs,
107 | logs,
108 | options,
109 | onChange,
110 | getTypeInfo,
111 | onClickTab,
112 | }: Props) {
113 | const internalOptions = useOptions()
114 |
115 | const [showDetails, setShowDetails] = useState(false)
116 |
117 | const title = options.title ?? internalOptions.title
118 |
119 | const fileDiff = diff[activeFile] ? diff[activeFile].ranges : []
120 |
121 | const error = compilerError || runtimeError
122 | const isError = !!error
123 |
124 | const style = mergeStyles(styles.editorPane, options.style)
125 | const sidebarStyle = mergeStyles(
126 | styles.sidebar,
127 | externalStyles.workspacesList
128 | )
129 |
130 | const headerElements = (
131 | <>
132 | {internalOptions.codesandbox && (
133 |
134 |
135 |
136 | )}
137 | {internalOptions.openInNewWindow && (
138 |
144 |
145 |
146 | )}
147 | {fullscreen && (
148 |
153 |
154 |
155 | )}
156 |
157 | >
158 | )
159 |
160 | const tabsElement = fileTabs.length > 1 && (
161 |
173 | {!title && headerElements}
174 |
175 | )
176 |
177 | const editorElement = (
178 |
191 | )
192 |
193 | return (
194 |
195 | {title && (
196 |
201 | {headerElements}
202 |
203 | )}
204 | {options.fileList === 'sidebar' ? (
205 |
206 | tab === activeFileTab)
210 | }
211 | onChangeActiveStepIndex={(index) => {
212 | onClickTab(fileTabs[index])
213 | }}
214 | showNextButton={false}
215 | steps={fileTabs}
216 | style={sidebarStyle}
217 | rowStyle={externalStyles.workspacesRow}
218 | rowStyleActive={externalStyles.workspacesRowActive}
219 | rowTitleStyle={externalStyles.workspacesRowTitle}
220 | rowTitleStyleActive={externalStyles.workspacesRowTitleActive}
221 | descriptionStyle={externalStyles.workspacesDescription}
222 | descriptionTextStyle={externalStyles.workspacesDescriptionText}
223 | buttonTextStyle={externalStyles.workspacesButtonText}
224 | buttonContainerStyle={externalStyles.workspacesButtonContainer}
225 | buttonWrapperStyle={externalStyles.workspacesButtonWrapper}
226 | dividerStyle={externalStyles.workspacesDivider}
227 | />
228 | {editorElement}
229 |
230 | ) : (
231 |
232 | {tabsElement}
233 | {editorElement}
234 |
235 | )}
236 | {showDetails && (
237 |
238 |
239 |
240 | {isError ? (
241 | <>
242 | {error?.description}
243 |
244 |
245 | {error?.errorMessage}
246 |
247 | >
248 | ) : (
249 | ''
250 | )}
251 |
252 |
253 |
254 |
255 | )}
256 |
269 | {strings.showDetails && (
270 |
277 | )}
278 |
279 |
280 | )
281 | })
282 |
--------------------------------------------------------------------------------
/src/components/workspace/panes/PlayerPane.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, useState } from 'react'
2 | import { useOptions } from '../../../contexts/OptionsContext'
3 | import { ConsoleCommand, LogCommand } from '../../../types/Messages'
4 | import { PlayerPaneOptions } from '../../../utils/Panes'
5 | import {
6 | columnStyle,
7 | mergeStyles,
8 | prefixObject,
9 | rowStyle,
10 | } from '../../../utils/Styles'
11 | import type { ExternalModule } from '../../player/VendorComponents'
12 | import Button from '../Button'
13 | import Console from '../Console'
14 | import Header from '../Header'
15 | import HeaderLink from '../HeaderLink'
16 | import { ReloadIcon } from '../Icons'
17 | import PlayerFrame from '../PlayerFrame'
18 | import { HorizontalSpacer } from '../Spacer'
19 | import Status from '../Status'
20 | import { ExternalStyles } from '../Workspace'
21 |
22 | const styles = prefixObject({
23 | playerPane: mergeStyles(columnStyle, { flex: '0 0 auto' }),
24 | column: columnStyle,
25 | row: rowStyle,
26 | })
27 |
28 | export interface Props {
29 | options: PlayerPaneOptions
30 | externalStyles: ExternalStyles
31 | environmentName: string
32 | sharedEnvironment: boolean
33 | files: Record
34 | detectedModules: ExternalModule[]
35 | registerBundledModules: boolean
36 | logs: LogCommand[]
37 | onPlayerRun: () => void
38 | onPlayerReady: () => void
39 | onPlayerReload: () => void
40 | onPlayerError: (codeVersion: number, message: string) => void
41 | onPlayerConsole: (codeVersion: number, payload: ConsoleCommand) => void
42 | }
43 |
44 | const PlayerPane = memo(
45 | React.forwardRef(function renderPlayer(
46 | {
47 | options,
48 | externalStyles,
49 | environmentName: environmentName,
50 | sharedEnvironment,
51 | files,
52 | logs,
53 | onPlayerRun,
54 | onPlayerReady,
55 | onPlayerReload,
56 | onPlayerError,
57 | onPlayerConsole,
58 | detectedModules,
59 | registerBundledModules,
60 | },
61 | ref
62 | ) {
63 | const {
64 | title,
65 | width,
66 | scale,
67 | platform,
68 | assetRoot,
69 | modules,
70 | styleSheet,
71 | css,
72 | prelude,
73 | statusBarHeight,
74 | statusBarColor,
75 | console,
76 | } = options
77 | const { strings } = useOptions()
78 |
79 | const [showLogs, setShowLogs] = useState(console?.visible)
80 |
81 | const style = mergeStyles(
82 | styles.playerPane,
83 | externalStyles.playerPane,
84 | options.style
85 | )
86 |
87 | return (
88 |
89 | {title && (
90 |
95 | {options.reloadable && (
96 | <>
97 |
98 |
99 |
100 |
101 | >
102 | )}
103 |
104 | )}
105 |
106 |
107 |
129 | {console && showLogs && (
130 |
1 && console.showFileName
136 | }
137 | showLineNumber={console.showLineNumber}
138 | logs={logs}
139 | renderReactElements={console.renderReactElements}
140 | />
141 | )}
142 |
143 | {console && console.collapsible !== false && (
144 |
151 |
154 |
155 | )}
156 |
157 |
158 | )
159 | })
160 | )
161 |
162 | export default PlayerPane
163 |
--------------------------------------------------------------------------------
/src/components/workspace/panes/StackPane.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo, ReactNode, useCallback, useMemo } from 'react'
2 | import { StackPaneOptions, PaneOptions } from '../../../utils/Panes'
3 | import TabContainer from '../TabContainer'
4 | import { Tab, getTabTitle, compareTabs } from '../../../utils/Tab'
5 | import { ExternalStyles } from '../Workspace'
6 |
7 | interface Props {
8 | options: StackPaneOptions
9 | externalStyles: ExternalStyles
10 | renderPane: (pane: PaneOptions, index: number) => ReactNode
11 | }
12 |
13 | type ExtendedTab = Tab & { pane: PaneOptions }
14 |
15 | export default memo(function StackPane({
16 | options,
17 | externalStyles,
18 | renderPane,
19 | }: Props) {
20 | const { children } = options
21 |
22 | const tabs: (Tab & { pane: PaneOptions })[] = useMemo(
23 | () =>
24 | children.map((pane, i) => ({
25 | title: pane.title || pane.type,
26 | index: i,
27 | pane: {
28 | ...pane,
29 | // A title bar is redundant, since the title shows in the stack tab
30 | title: undefined,
31 | },
32 | changed: false,
33 | })),
34 | [children]
35 | )
36 |
37 | const callback = useCallback((tab) => renderPane(tab.pane, tab.index), [
38 | tabs,
39 | renderPane,
40 | ])
41 |
42 | return (
43 |
56 | )
57 | })
58 |
--------------------------------------------------------------------------------
/src/components/workspace/panes/TranspilerPane.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo } from 'react'
2 | import { TranspilerPaneOptions } from '../../../utils/Panes'
3 | import {
4 | columnStyle,
5 | mergeStyles,
6 | prefixObject,
7 | rowStyle,
8 | } from '../../../utils/Styles'
9 | import Editor from '../Editor'
10 | import Header from '../Header'
11 | import type { ExternalStyles, PlaygroundOptions } from '../Workspace'
12 |
13 | const styles = prefixObject({
14 | transpilerPane: columnStyle,
15 | column: columnStyle,
16 | row: rowStyle,
17 | })
18 |
19 | interface Props {
20 | options: TranspilerPaneOptions
21 | externalStyles: ExternalStyles
22 | activeFile: string
23 | transpilerCache: Record
24 | playgroundOptions: PlaygroundOptions
25 | }
26 |
27 | export default memo(function TranspilerPane({
28 | options,
29 | externalStyles,
30 | activeFile,
31 | transpilerCache,
32 | playgroundOptions,
33 | }: Props) {
34 | const { title } = options
35 |
36 | const style = mergeStyles(styles.transpilerPane, options.style)
37 |
38 | return (
39 |
40 | {title && (
41 |
46 | )}
47 |
54 |
55 | )
56 | })
57 |
--------------------------------------------------------------------------------
/src/components/workspace/panes/WorkspacesPane.tsx:
--------------------------------------------------------------------------------
1 | import React, { memo } from 'react'
2 | import { WorkspacesPaneOptions } from '../../../utils/Panes'
3 | import WorkspacesList, { Step } from '../WorkspacesList'
4 | import Header from '../Header'
5 | import { mergeStyles, prefixObject, columnStyle } from '../../../utils/Styles'
6 | import { ExternalStyles } from '../Workspace'
7 |
8 | const styles = prefixObject({
9 | workspacesPane: mergeStyles(columnStyle, {
10 | width: 220,
11 | overflowX: 'hidden',
12 | overflowY: 'auto',
13 | }),
14 | })
15 |
16 | interface Props {
17 | options: WorkspacesPaneOptions
18 | externalStyles: ExternalStyles
19 | activeStepIndex: number
20 | workspaces: Step[]
21 | onChangeActiveStepIndex: (index: number) => void
22 | }
23 |
24 | export default memo(function WorkspacesPane({
25 | options,
26 | externalStyles,
27 | workspaces,
28 | activeStepIndex,
29 | onChangeActiveStepIndex,
30 | }: Props) {
31 | const { title } = options
32 |
33 | const style = mergeStyles(
34 | styles.workspacesPane,
35 | externalStyles.workspacesPane,
36 | options.style
37 | )
38 |
39 | return (
40 |
41 | {title && (
42 |
47 | )}
48 |
64 |
65 | )
66 | })
67 |
--------------------------------------------------------------------------------
/src/constants/DefaultCode.ts:
--------------------------------------------------------------------------------
1 | export const javaScript = `console.log('Hello, playgrounds!')`
2 |
3 | export const reactNative = `import React from 'react'
4 | import { StyleSheet, Text, View } from 'react-native'
5 |
6 | export default function App() {
7 | return (
8 |
9 |
10 | Welcome to React Native!
11 |
12 |
13 | )
14 | }
15 |
16 | const styles = StyleSheet.create({
17 | container: {
18 | flex: 1,
19 | justifyContent: 'center',
20 | alignItems: 'center',
21 | backgroundColor: '#F5FCFF',
22 | },
23 | welcome: {
24 | fontSize: 20,
25 | textAlign: 'center',
26 | margin: 10,
27 | },
28 | })
29 | `
30 |
31 | export const react = `import React from 'react'
32 | import ReactDOM from 'react-dom'
33 |
34 | function App() {
35 | const style = {
36 | padding: '40px',
37 | textAlign: 'center',
38 | background: 'lightskyblue',
39 | }
40 |
41 | return Welcome to React!
42 | }
43 |
44 | ReactDOM.render(, document.querySelector('#app'))
45 | `
46 |
--------------------------------------------------------------------------------
/src/constants/Phones.ts:
--------------------------------------------------------------------------------
1 | // The MIT License (MIT)
2 | //
3 | // Copyright (c) 2014 Koen Bok
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | import iphone6silver from '../assets/iphone-6-silver.svg'
24 |
25 | type Device = {
26 | deviceImageUrl: string
27 | deviceImageWidth: number
28 | deviceImageHeight: number
29 | screenWidth: number
30 | screenHeight: number
31 | }
32 |
33 | // Devices have pixel density of 2, but we also zoom in for visibility at small sizes.
34 | const phones: Record = {
35 | ios: {
36 | deviceImageUrl: iphone6silver,
37 | deviceImageWidth: 870,
38 | deviceImageHeight: 1738,
39 | screenWidth: 750,
40 | screenHeight: 1334,
41 | },
42 | android: {
43 | // Device image taken from the framerjs codebase
44 | deviceImageUrl:
45 | 'https://cdn.rawgit.com/koenbok/Framer/master/extras/DeviceResources/google-nexus-5x.png',
46 | deviceImageWidth: 1204,
47 | deviceImageHeight: 2432,
48 | screenWidth: 1080,
49 | screenHeight: 1920,
50 | },
51 | }
52 |
53 | export default phones
54 |
--------------------------------------------------------------------------------
/src/contexts/OptionsContext.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react'
2 | import { InternalOptions } from '../utils/options'
3 |
4 | const OptionsContext = createContext(undefined)
5 |
6 | export const OptionsProvider = OptionsContext.Provider
7 |
8 | export const useOptions = () => {
9 | const options = useContext(OptionsContext)
10 |
11 | if (!options) {
12 | throw new Error(`Supply a Options component using OptionsContext.Provider`)
13 | }
14 |
15 | return options
16 | }
17 |
--------------------------------------------------------------------------------
/src/environments/IEnvironment.tsx:
--------------------------------------------------------------------------------
1 | import { ExternalModuleDescription } from '../components/player/VendorComponents'
2 | import type { PlayerStyles } from '../player'
3 | import type { Message } from '../types/Messages'
4 |
5 | declare global {
6 | interface Window {
7 | regeneratorRuntime: unknown
8 | __message: (message: Message) => void
9 | }
10 | }
11 |
12 | export type EvaluationContext = {
13 | fileMap: Record
14 | entry: string
15 | codeVersion: number
16 | requireCache: Record
17 | }
18 |
19 | export interface EnvironmentOptions {
20 | id: string
21 | assetRoot: string
22 | prelude: string
23 | statusBarHeight: number
24 | statusBarColor: string
25 | sharedEnvironment: boolean
26 | styles: PlayerStyles
27 | modules: ExternalModuleDescription[]
28 | detectedModules: ExternalModuleDescription[]
29 | registerBundledModules: boolean
30 | }
31 |
32 | export interface IEnvironment {
33 | initialize(options: EnvironmentOptions): Promise
34 | }
35 |
--------------------------------------------------------------------------------
/src/environments/html-environment.tsx:
--------------------------------------------------------------------------------
1 | import { bundle } from 'packly'
2 | import consoleProxy, { ConsoleProxy } from '../components/player/ConsoleProxy'
3 | import * as path from '../utils/path'
4 | import {
5 | bindConsoleLogMethods,
6 | createWindowErrorHandler,
7 | initializeCommunication,
8 | } from '../utils/playerCommunication'
9 | import { createAppLayout } from '../utils/PlayerUtils'
10 | import {
11 | EnvironmentOptions,
12 | EvaluationContext,
13 | IEnvironment,
14 | } from './IEnvironment'
15 |
16 | // Inline stylesheets and scripts
17 | function generateBundle(context: EvaluationContext) {
18 | return bundle({
19 | entry: context.entry,
20 | request({ origin, url }) {
21 | if (origin === undefined) return context.fileMap[url]
22 |
23 | // Don't inline (external) urls starting with http://, https://, or //
24 | if (/^(https?)?\/\//.test(url)) return undefined
25 |
26 | // Inline absolute urls
27 | if (url.startsWith('/')) return context.fileMap[url.slice(1)]
28 |
29 | // Inline relative urls
30 | const lookup = path.join(path.dirname(origin), url)
31 |
32 | return context.fileMap[lookup]
33 | },
34 | })
35 | }
36 |
37 | function bindIframeCommunication(
38 | iframe: HTMLIFrameElement,
39 | { id, codeVersion }: { id: string; codeVersion: number }
40 | ) {
41 | const iframeWindow = iframe.contentWindow! as Window & {
42 | console: ConsoleProxy
43 | }
44 |
45 | bindConsoleLogMethods({
46 | consoleProxy: iframeWindow.console,
47 | codeVersion,
48 | id,
49 | prefixLineCount: 0,
50 | sharedEnvironment: false,
51 | })
52 |
53 | iframeWindow.onerror = createWindowErrorHandler({
54 | codeVersion,
55 | id,
56 | prefixLineCount: 0,
57 | })
58 | }
59 |
60 | export class HTMLEnvironment implements IEnvironment {
61 | async initialize({
62 | id,
63 | sharedEnvironment,
64 | styles,
65 | }: EnvironmentOptions): Promise {
66 | const { appElement } = createAppLayout(document, styles)
67 | const iframe = document.createElement('iframe')
68 | iframe.style.width = '100%'
69 | iframe.style.height = '100%'
70 |
71 | appElement.appendChild(iframe)
72 |
73 | initializeCommunication({
74 | id,
75 | prefixLineCount: 0,
76 | sharedEnvironment,
77 | consoleProxy,
78 | onRunApplication: (context) => {
79 | const html = generateBundle(context)
80 |
81 | const document = iframe.contentDocument
82 |
83 | if (!document) return
84 |
85 | // https://stackoverflow.com/questions/5784638/replace-entire-content-of-iframe
86 | document.close()
87 | document.open()
88 |
89 | bindIframeCommunication(iframe, {
90 | id,
91 | codeVersion: context.codeVersion,
92 | })
93 |
94 | document.write(html)
95 | document.close()
96 | },
97 | })
98 | }
99 | }
100 |
101 | export default new HTMLEnvironment()
102 |
--------------------------------------------------------------------------------
/src/environments/python-environment.tsx:
--------------------------------------------------------------------------------
1 | import { consoleLogRNWP } from '../components/player/ConsoleProxy'
2 | import { Message } from '../types/Messages'
3 | import { workerRequest } from '../utils/WorkerRequest'
4 | import type {
5 | EnvironmentOptions,
6 | EvaluationContext,
7 | IEnvironment,
8 | } from './IEnvironment'
9 | import type { TransferableImage } from '../workers/pythonWorker'
10 | import hasProperty from '../utils/hasProperty'
11 |
12 | function sendMessage(sharedEnvironment: boolean, message: Message) {
13 | if (sharedEnvironment) {
14 | parent.__message(message)
15 | } else {
16 | try {
17 | parent.postMessage(JSON.stringify(message), '*')
18 | } catch {}
19 | }
20 | }
21 |
22 | let pythonWorker: Promise = import(
23 | '../python-worker.js'
24 | ).then((worker) => (worker as any).default())
25 |
26 | function pythonRequest(payload: unknown): Promise {
27 | return pythonWorker.then((worker) => workerRequest(worker, payload))
28 | }
29 |
30 | let requestId: number = 0
31 |
32 | // Ensure we only show logs for the most recent code
33 | let listener: ((event: MessageEvent) => void) | undefined
34 |
35 | // Buffer any requests so that only a single request is queued up at any time.
36 | // We don't want to run requests if we already have another request to run after,
37 | // since we'd end up discarding the results anyway.
38 | let hasOutstandingRequest = false
39 | let nextRequest: EvaluationContext | undefined
40 |
41 | function run(options: EnvironmentOptions, context: EvaluationContext) {
42 | const { id, sharedEnvironment, modules } = options
43 | const { entry, fileMap, codeVersion } = context
44 |
45 | const currentRequestId = requestId++
46 |
47 | if (hasOutstandingRequest) {
48 | nextRequest = context
49 | return
50 | } else {
51 | hasOutstandingRequest = true
52 | }
53 |
54 | pythonRequest({
55 | type: 'run',
56 | code: fileMap[entry],
57 | requestId: currentRequestId,
58 | }).then(() => {
59 | hasOutstandingRequest = false
60 |
61 | if (nextRequest) {
62 | const request = nextRequest
63 | nextRequest = undefined
64 | run(options, request)
65 | }
66 | })
67 |
68 | pythonWorker.then((worker) => {
69 | if (listener) {
70 | worker.removeEventListener('message', listener)
71 | }
72 |
73 | listener = ({ data }: MessageEvent) => {
74 | if (
75 | !data ||
76 | !data.payload ||
77 | data.payload.requestId !== currentRequestId
78 | ) {
79 | return
80 | }
81 |
82 | if (data.type === 'log') {
83 | const { line, col, logs } = data.payload as {
84 | line: number
85 | col: number
86 | logs: string[]
87 | }
88 |
89 | const transformedLogs = logs.map((value) =>
90 | isTransferableImage(value) ? createImageElement(value) : value
91 | )
92 |
93 | consoleLogRNWP(
94 | sendMessage.bind(null, sharedEnvironment),
95 | id,
96 | codeVersion,
97 | entry,
98 | line,
99 | col,
100 | 'visible',
101 | ...transformedLogs
102 | )
103 | } else if (data.type === 'error') {
104 | const { message } = data.payload as {
105 | message: string
106 | }
107 |
108 | sendMessage(sharedEnvironment, {
109 | id,
110 | codeVersion,
111 | type: 'error',
112 | payload: message,
113 | })
114 | }
115 | }
116 |
117 | worker.addEventListener('message', listener)
118 | })
119 | }
120 |
121 | const Environment: IEnvironment = {
122 | initialize(options) {
123 | window.onmessage = (e: MessageEvent) => {
124 | if (!e.data || e.data.source !== 'rnwp') return
125 |
126 | run(options, e.data as EvaluationContext)
127 | }
128 |
129 | return pythonRequest({
130 | type: 'init',
131 | }).then((_) => {})
132 | },
133 | }
134 |
135 | function isTransferableImage(value: unknown): value is TransferableImage {
136 | return (
137 | typeof value === 'object' &&
138 | value !== null &&
139 | hasProperty(value, 'marker') &&
140 | value.marker === '__rnwp_transferable_image__'
141 | )
142 | }
143 |
144 | /**
145 | * We can't transfer ImageData objects for some reason, so we instead
146 | * pass the raw data and recreate an ImageData object
147 | */
148 | function createImageElement(data: TransferableImage) {
149 | const imageData = new ImageData(data.buffer, data.width, data.height)
150 |
151 | const canvas = document.createElement('canvas')
152 | canvas.width = imageData.width
153 | canvas.height = imageData.height
154 |
155 | const context = canvas.getContext('2d')!
156 | context.putImageData(imageData, 0, 0)
157 |
158 | const image = new Image()
159 | image.src = canvas.toDataURL()
160 |
161 | return image
162 | }
163 |
164 | export default Environment
165 |
--------------------------------------------------------------------------------
/src/environments/react-environment.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type ReactDOM from 'react-dom'
3 | import hasProperty from '../utils/hasProperty'
4 | import {
5 | AfterEvaluateOptions,
6 | BeforeEvaluateOptions,
7 | JavaScriptEnvironment,
8 | } from './javascript-environment'
9 |
10 | class ReactEnvironment extends JavaScriptEnvironment {
11 | lastReactDOM: typeof ReactDOM | null = null
12 |
13 | beforeEvaluate({ host }: BeforeEvaluateOptions) {
14 | this.lastReactDOM?.unmountComponentAtNode(host)
15 | }
16 |
17 | afterEvaluate({ context, host, require }: AfterEvaluateOptions) {
18 | const currentReactDOM = require('react-dom') as typeof ReactDOM
19 | this.lastReactDOM = currentReactDOM
20 |
21 | const EntryComponent = context.requireCache[context.entry]
22 |
23 | if (
24 | EntryComponent &&
25 | typeof EntryComponent === 'object' &&
26 | hasProperty(EntryComponent, 'default')
27 | ) {
28 | const Component = EntryComponent.default as React.FunctionComponent
29 | currentReactDOM.render(, host)
30 | }
31 |
32 | const renderedElement = host.firstElementChild as HTMLElement | undefined
33 |
34 | // After rendering, add 'overflow: hidden' to prevent scrollbars
35 | if (renderedElement) {
36 | renderedElement.style.overflow = 'hidden'
37 | }
38 | }
39 | }
40 |
41 | export default new ReactEnvironment()
42 |
--------------------------------------------------------------------------------
/src/environments/react-native-environment.tsx:
--------------------------------------------------------------------------------
1 | import type ReactDOM from 'react-dom'
2 | import * as ReactNative from 'react-native-web'
3 | import hasProperty from '../utils/hasProperty'
4 | import type { EnvironmentOptions } from './IEnvironment'
5 | import {
6 | AfterEvaluateOptions,
7 | BeforeEvaluateOptions,
8 | JavaScriptEnvironment,
9 | } from './javascript-environment'
10 |
11 | const DEFAULT_APP_NAME = 'Main Export'
12 |
13 | class ReactNativeEnvironment extends JavaScriptEnvironment {
14 | lastReactDOM: typeof ReactDOM | null = null
15 | lastReactNative: typeof ReactNative | null = null
16 |
17 | initialize(options: EnvironmentOptions) {
18 | if (options.registerBundledModules) {
19 | this.nodeModules['react-native'] = ReactNative
20 |
21 | Object.assign(window, {
22 | ReactNative,
23 | })
24 | }
25 |
26 | return super.initialize(options)
27 | }
28 |
29 | beforeEvaluate({ host }: BeforeEvaluateOptions) {
30 | const currentReactNative = this.lastReactNative
31 |
32 | if (
33 | currentReactNative &&
34 | currentReactNative.AppRegistry.getAppKeys().length > 0
35 | ) {
36 | this.lastReactDOM?.unmountComponentAtNode(host)
37 | }
38 | }
39 |
40 | afterEvaluate({ context, host, require }: AfterEvaluateOptions) {
41 | const currentReactNative = require('react-native') as typeof ReactNative
42 | const currentReactDOM = require('react-dom') as typeof ReactDOM
43 |
44 | this.lastReactNative = currentReactNative
45 | this.lastReactDOM = currentReactDOM
46 |
47 | const { AppRegistry, Dimensions } = currentReactNative
48 |
49 | // Attempt to register the default export of the entry file
50 | if (
51 | AppRegistry.getAppKeys().length === 0 ||
52 | (AppRegistry.getAppKeys().length === 1 &&
53 | AppRegistry.getAppKeys()[0] === DEFAULT_APP_NAME)
54 | ) {
55 | const EntryComponent = context.requireCache[context.entry]
56 |
57 | if (
58 | EntryComponent &&
59 | typeof EntryComponent === 'object' &&
60 | hasProperty(EntryComponent, 'default')
61 | ) {
62 | AppRegistry.registerComponent(
63 | DEFAULT_APP_NAME,
64 | () => EntryComponent.default
65 | )
66 | }
67 | }
68 |
69 | const appKeys = AppRegistry.getAppKeys()
70 |
71 | // If no component was registered, bail out
72 | if (appKeys.length === 0) return
73 |
74 | // Initialize window dimensions (sometimes this doesn't happen automatically?)
75 | Dimensions._update()
76 |
77 | AppRegistry.runApplication(appKeys[0], {
78 | rootTag: host,
79 | })
80 |
81 | const renderedElement = host.firstElementChild as HTMLElement | undefined
82 |
83 | // After rendering, add 'overflow: hidden' to prevent scrollbars
84 | if (renderedElement) {
85 | renderedElement.style.overflow = 'hidden'
86 | }
87 | }
88 | }
89 |
90 | export default new ReactNativeEnvironment()
91 |
--------------------------------------------------------------------------------
/src/hooks/useRerenderEffect.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | export default function useRerenderEffect<
4 | T extends (...args: any[]) => any,
5 | U extends any[]
6 | >(f: T, deps: U) {
7 | const didMount = useRef(false)
8 |
9 | useEffect(() => {
10 | if (didMount.current) {
11 | f()
12 | } else {
13 | didMount.current = true
14 | }
15 | }, deps)
16 | }
17 |
--------------------------------------------------------------------------------
/src/hooks/useResponsiveBreakpoint.ts:
--------------------------------------------------------------------------------
1 | import useWindowDimensions from './useWindowDimensions'
2 |
3 | export default function useResponsiveBreakpoint(breakpoints: number[]): number {
4 | const dimensions = useWindowDimensions()
5 |
6 | if (dimensions.width === undefined) {
7 | return breakpoints.length - 1
8 | }
9 |
10 | const index = breakpoints.findIndex(
11 | (breakpoint) => breakpoint > dimensions.width
12 | )
13 |
14 | return index >= 0 ? index : breakpoints.length - 1
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/useWindowDimensions.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export default function useWindowDimensions() {
4 | const [dimensions, setDimensions] = useState({
5 | width: window.innerWidth,
6 | // height: window.innerHeight,
7 | })
8 |
9 | useEffect(() => {
10 | const handleResize = () => {
11 | setDimensions({
12 | width: window.innerWidth,
13 | // height: window.innerHeight,
14 | })
15 | }
16 |
17 | window.addEventListener('resize', handleResize)
18 |
19 | return () => window.removeEventListener('resize', handleResize)
20 | }, [])
21 |
22 | return dimensions
23 | }
24 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import './styles/reset.css'
2 | import 'codemirror/lib/codemirror.css'
3 | import './styles/codemirror-theme.css'
4 | import './styles/index.css' // Load after CodeMirror, since it overrides defaults
5 |
6 | import React from 'react'
7 | import ReactDOM from 'react-dom'
8 |
9 | import { getHashString, buildHashString } from './utils/HashString'
10 | import { prefixAndApply } from './utils/Styles'
11 | import { appendCSS } from './utils/CSS'
12 | import { normalize, PublicOptions, getFileExtensions } from './utils/options'
13 | import App from './components/workspace/App'
14 | import { OptionsProvider } from './contexts/OptionsContext'
15 |
16 | const { data = '{}', preset } = getHashString()
17 |
18 | const publicOptions: PublicOptions = JSON.parse(data)
19 |
20 | if (preset) {
21 | publicOptions.preset = decodeURIComponent(preset)
22 | }
23 |
24 | const internalOptions = normalize(publicOptions)
25 |
26 | const { css, _css, targetOrigin, ...rest } = internalOptions
27 |
28 | const documentCSS = css || _css
29 |
30 | if (documentCSS) {
31 | appendCSS(document, documentCSS)
32 | }
33 |
34 | const mount = document.getElementById('player-root') as HTMLDivElement
35 |
36 | // Set mount node to flex in a vendor-prefixed way
37 | prefixAndApply({ display: 'flex' }, mount)
38 |
39 | function render() {
40 | ReactDOM.render(
41 |
42 |
43 | ,
44 | mount
45 | )
46 | }
47 |
48 | const extensions = getFileExtensions(internalOptions)
49 | const editorModes: Promise[] = []
50 |
51 | if (extensions.includes('.py')) {
52 | editorModes.push(import('codemirror/mode/python/python' as any))
53 | }
54 | if (extensions.includes('.html')) {
55 | editorModes.push(import('codemirror/mode/htmlmixed/htmlmixed' as any))
56 | }
57 | if (extensions.includes('.css')) {
58 | editorModes.push(import('codemirror/mode/css/css' as any))
59 | }
60 |
61 | Promise.all(editorModes).then(render)
62 |
63 | function onChange(files: Record) {
64 | const merged = {
65 | ...publicOptions,
66 | ...(publicOptions.files
67 | ? { files }
68 | : { code: files[Object.keys(files)[0]] }),
69 | }
70 |
71 | if (preset) {
72 | delete merged.preset
73 | }
74 |
75 | const data = JSON.stringify(merged)
76 |
77 | const hashString = buildHashString({ ...(preset ? { preset } : {}), data })
78 |
79 | if (targetOrigin && parent) {
80 | parent.postMessage(data, targetOrigin)
81 | }
82 |
83 | try {
84 | history.replaceState({}, '', hashString)
85 | } catch (e) {
86 | // Browser doesn't support pushState
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/src/player.tsx:
--------------------------------------------------------------------------------
1 | import type { CSSProperties } from 'react'
2 | import VendorComponents from './components/player/VendorComponents'
3 | import type {
4 | EnvironmentOptions,
5 | IEnvironment,
6 | } from './environments/IEnvironment'
7 | import JavaScriptEnvironment from './environments/javascript-environment'
8 | import { appendCSS } from './utils/CSS'
9 | import { getHashString } from './utils/HashString'
10 | import { prefixObject } from './utils/Styles'
11 |
12 | const {
13 | environmentName = 'react-native',
14 | id = '0',
15 | assetRoot = '',
16 | detectedModules: rawDetectedModules = '[]',
17 | registerBundledModules = 'true',
18 | modules: rawModules = '[]',
19 | styleSheet = 'reset',
20 | css = '',
21 | statusBarHeight = '0',
22 | statusBarColor = 'black',
23 | prelude = '',
24 | sharedEnvironment = 'true',
25 | styles = '{}',
26 | } = getHashString()
27 |
28 | if (styleSheet === 'reset') {
29 | require('./styles/reset.css')
30 | }
31 |
32 | require('./styles/player.css')
33 |
34 | if (css) {
35 | appendCSS(document, css)
36 | }
37 |
38 | export type PlayerStyles = {
39 | playerRoot: CSSProperties
40 | playerWrapper: CSSProperties
41 | playerApp: CSSProperties
42 | }
43 |
44 | const parsedStyles: PlayerStyles = prefixObject(
45 | Object.assign(
46 | {
47 | playerRoot: { display: 'flex' },
48 | playerWrapper: {
49 | flex: '1 1 auto',
50 | alignSelf: 'stretch',
51 | width: '100%',
52 | height: '100%',
53 | display: 'flex',
54 | },
55 | playerApp: {
56 | flex: '1 1 auto',
57 | alignSelf: 'stretch',
58 | width: '100%',
59 | height: '100%',
60 | display: 'flex',
61 | },
62 | },
63 | JSON.parse(styles)
64 | )
65 | )
66 |
67 | const asyncEnvironment: Promise =
68 | environmentName === 'javascript'
69 | ? Promise.resolve(JavaScriptEnvironment)
70 | : import('./environments/' + environmentName + '-environment').then(
71 | (module) => module.default
72 | )
73 |
74 | asyncEnvironment.then((environment: IEnvironment) => {
75 | const options: EnvironmentOptions = {
76 | id,
77 | assetRoot,
78 | prelude,
79 | styles: parsedStyles,
80 | statusBarHeight: parseFloat(statusBarHeight),
81 | statusBarColor: statusBarColor,
82 | sharedEnvironment: sharedEnvironment === 'true',
83 | modules: JSON.parse(rawModules).map(
84 | VendorComponents.normalizeExternalModule
85 | ),
86 | detectedModules: JSON.parse(rawDetectedModules).map(
87 | VendorComponents.normalizeExternalModule
88 | ),
89 | registerBundledModules: registerBundledModules === 'true',
90 | }
91 |
92 | return environment.initialize(options).then(handleEnvironmentReady)
93 | })
94 |
95 | // Notify the parent that we're ready to receive/run compiled code
96 | function handleEnvironmentReady() {
97 | try {
98 | parent.postMessage(JSON.stringify({ id, type: 'ready' }), '*')
99 | } catch {}
100 | }
101 |
--------------------------------------------------------------------------------
/src/python-worker.js:
--------------------------------------------------------------------------------
1 | import './workers/pythonWorker'
2 |
--------------------------------------------------------------------------------
/src/reducers/workspace.ts:
--------------------------------------------------------------------------------
1 | import { PublicError } from '../components/workspace/Workspace'
2 | import { LogCommand } from '../types/Messages'
3 | import { getErrorDetails } from '../utils/ErrorMessage'
4 | import { PaneOptions } from '../utils/Panes'
5 | import { Tab } from '../utils/Tab'
6 |
7 | export const types = {
8 | COMPILED: 'COMPILED',
9 | TRANSPILED: 'TRANSPILED',
10 | BABEL_CODE: 'BABEL_CODE',
11 | BABEL_ERROR: 'BABEL_ERROR',
12 | CODE_CHANGE: 'CODE_CHANGE',
13 | PLAYER_RUN: 'PLAYER_RUN',
14 | PLAYER_ERROR: 'PLAYER_ERROR',
15 | CONSOLE_APPEND: 'CONSOLE_APPEND',
16 | CONSOLE_CLEAR: 'CONSOLE_CLEAR',
17 | OPEN_EDITOR_TAB: 'OPEN_EDITOR_TAB',
18 | } as const
19 |
20 | export const actionCreators = {
21 | compiled: (filename: string, code: string) => ({
22 | type: types.COMPILED,
23 | filename,
24 | code,
25 | }),
26 | transpiled: (filename: string, code: string) => ({
27 | type: types.TRANSPILED,
28 | filename,
29 | code,
30 | }),
31 | compilerSuccess: (filename: string) => ({
32 | type: types.BABEL_CODE,
33 | filename,
34 | }),
35 | compilerError: (filename: string, message: string) => ({
36 | type: types.BABEL_ERROR,
37 | filename,
38 | message,
39 | }),
40 | codeChange: (filename: string, code: string) => ({
41 | type: types.CODE_CHANGE,
42 | filename,
43 | code,
44 | }),
45 | playerRun: () => ({
46 | type: types.PLAYER_RUN,
47 | }),
48 | playerError: (message: string) => ({
49 | type: types.PLAYER_ERROR,
50 | message,
51 | }),
52 | consoleAppend: (command: LogCommand) => ({
53 | type: types.CONSOLE_APPEND,
54 | command,
55 | }),
56 | consoleClear: () => ({
57 | type: types.CONSOLE_CLEAR,
58 | }),
59 | openEditorTab: (tab: Tab) => ({
60 | type: types.OPEN_EDITOR_TAB,
61 | tab,
62 | }),
63 | }
64 |
65 | type ValueOf = T[keyof T]
66 |
67 | export type Action = ReturnType>
68 |
69 | export interface State {
70 | codeVersion: number
71 | compilerError?: PublicError
72 | runtimeError?: PublicError
73 | logs: LogCommand[]
74 | activeFile: string
75 | codeCache: Record
76 | playerCache: Record
77 | transpilerCache: Record
78 | fileTabs: Tab[]
79 | activeFileTab?: Tab
80 | }
81 |
82 | export const initialState = ({
83 | initialTab,
84 | fileTabs,
85 | }: {
86 | initialTab: string
87 | fileTabs: Tab[]
88 | }): State => ({
89 | codeVersion: 0,
90 | compilerError: undefined,
91 | runtimeError: undefined,
92 | logs: [],
93 | activeFile: initialTab,
94 | // The current code files, for propagating to index.tsx
95 | codeCache: {},
96 | // Compiled files for the player
97 | playerCache: {},
98 | // Compiled files for the transpiler
99 | transpilerCache: {},
100 | fileTabs,
101 | activeFileTab: fileTabs.find((tab) => tab.title === initialTab),
102 | })
103 |
104 | export function reducer(state: State, action: Action): State {
105 | switch (action.type) {
106 | case types.COMPILED: {
107 | return {
108 | ...state,
109 | codeVersion: state.codeVersion + 1,
110 | playerCache: {
111 | ...state.playerCache,
112 | [action.filename]: action.code,
113 | },
114 | }
115 | }
116 | case types.TRANSPILED: {
117 | return {
118 | ...state,
119 | transpilerCache: {
120 | ...state.transpilerCache,
121 | [action.filename]: action.code,
122 | },
123 | }
124 | }
125 | case types.BABEL_CODE: {
126 | return state.compilerError !== undefined &&
127 | state.compilerError.filename === action.filename
128 | ? {
129 | ...state,
130 | compilerError: undefined,
131 | }
132 | : state
133 | }
134 | case types.BABEL_ERROR: {
135 | return {
136 | ...state,
137 | compilerError: {
138 | filename: action.filename,
139 | ...getErrorDetails(action.message),
140 | },
141 | }
142 | }
143 | case types.CODE_CHANGE: {
144 | return {
145 | ...state,
146 | codeCache: {
147 | ...state.codeCache,
148 | [action.filename]: action.code,
149 | },
150 | }
151 | }
152 | case types.PLAYER_RUN: {
153 | return state.runtimeError !== undefined
154 | ? {
155 | ...state,
156 | runtimeError: undefined,
157 | }
158 | : state
159 | }
160 | case types.PLAYER_ERROR: {
161 | // TODO: Runtime errors should indicate which file they're coming from,
162 | // and only cause a line highlight on that file.
163 | return {
164 | ...state,
165 | runtimeError: getErrorDetails(action.message),
166 | }
167 | }
168 | case types.CONSOLE_APPEND: {
169 | return {
170 | ...state,
171 | logs: [...state.logs, action.command],
172 | }
173 | }
174 | case types.CONSOLE_CLEAR:
175 | return state.logs.length > 0
176 | ? {
177 | ...state,
178 | logs: [],
179 | }
180 | : state
181 | case types.OPEN_EDITOR_TAB: {
182 | return {
183 | ...state,
184 | activeFile: action.tab.title,
185 | activeFileTab: action.tab,
186 | }
187 | }
188 | }
189 | }
190 |
--------------------------------------------------------------------------------
/src/styles/codemirror-theme.css:
--------------------------------------------------------------------------------
1 | .cm-s-react {
2 | font-family: 'source-code-pro', Menlo, 'Courier New', Consolas, monospace;
3 | font-size: 13px;
4 | line-height: 20px;
5 | color: #333;
6 | }
7 |
8 | .cm-s-react .CodeMirror-linenumber {
9 | color: #d8d8d8;
10 | padding: 0 3px 0 3px;
11 | font-size: 10px;
12 | line-height: 22px;
13 | }
14 |
15 | .cm-s-react .CodeMirror-gutters {
16 | background: white;
17 | border-left: 4px solid rgba(238, 238, 238, 1);
18 | border-right: 0px;
19 | }
20 |
21 | .cm-s-react span.cm-keyword {
22 | color: rgb(59, 108, 212);
23 | }
24 | .cm-s-react span.cm-atom {
25 | color: #c92c2c;
26 | }
27 | .cm-s-react span.cm-number {
28 | color: #c92c2c;
29 | }
30 | .cm-s-react span.cm-variable {
31 | color: black;
32 | }
33 | .cm-s-react span.cm-variable-2 {
34 | color: #0000c0;
35 | }
36 | .cm-s-react span.cm-variable-3 {
37 | color: #0000c0;
38 | }
39 | .cm-s-react span.cm-property {
40 | color: black;
41 | }
42 | .cm-s-react span.cm-operator {
43 | color: black;
44 | }
45 | .cm-s-react span.cm-comment {
46 | color: #7d8b99;
47 | }
48 | .cm-s-react span.cm-string,
49 | .cm-s-react span.cm-string-2,
50 | .cm-s-react span.cm-tag {
51 | color: #2e9f74;
52 | }
53 | .cm-s-react span.cm-link {
54 | color: #c92c2c;
55 | }
56 | .cm-s-react span.cm-bracket {
57 | color: #555;
58 | }
59 |
60 | .cm-s-react .CodeMirror-activeline-background {
61 | background: #e8f2ff;
62 | }
63 | .cm-s-react .CodeMirror-matchingtag {
64 | background: transparent;
65 | }
66 | .cm-s-react .cm-tag.CodeMirror-matchingtag:not(.cm-bracket) {
67 | text-decoration: underline;
68 | }
69 |
70 | @keyframes cm-line-warning {
71 | 0% {
72 | background-color: white;
73 | }
74 | 66% {
75 | background-color: white;
76 | }
77 | 100% {
78 | background-color: #ffdada;
79 | }
80 | }
81 |
82 | .cm-s-react .cm-line-error {
83 | background-color: #ffdada;
84 | animation: cm-line-warning 0.5s;
85 | }
86 |
87 | .cm-s-react .cm-line-changed {
88 | background-color: rgba(59, 160, 26, 0.18);
89 | /* animation: cm-line-warning 0.5s; */
90 | }
91 |
92 | .cm-s-react .cm-line-changed .CodeMirror-linenumber {
93 | color: rgba(83, 171, 57, 0.42);
94 | }
95 |
96 | /* read-only styles */
97 |
98 | .read-only .CodeMirror,
99 | .read-only .CodeMirror-gutters {
100 | background: rgb(238, 238, 238);
101 | }
102 |
103 | .read-only .CodeMirror-linenumber {
104 | color: #bcbcbc;
105 | }
106 |
107 | /* tooltip */
108 |
109 | .CodeMirror-tooltip {
110 | font-size: 13px;
111 | line-height: 20px;
112 | white-space: pre-wrap;
113 | background-color: white;
114 | border: 1px solid #ccc;
115 | box-shadow: 0 0 1px rgba(0, 0, 0, 0.2);
116 |
117 | pointer-events: none;
118 | position: absolute;
119 | z-index: 10;
120 | }
121 |
--------------------------------------------------------------------------------
/src/styles/index.css:
--------------------------------------------------------------------------------
1 | html,
2 | body,
3 | #player-root {
4 | width: 100%;
5 | height: 100%;
6 | min-width: 0;
7 | min-height: 0;
8 | }
9 |
10 | #player-root {
11 | display: flex;
12 | }
13 |
14 | .CodeMirror {
15 | width: 100%;
16 | height: 100%;
17 | }
18 |
19 | .markdown {
20 | line-height: 1.4;
21 | }
22 |
23 | .markdown em {
24 | font-style: italic;
25 | }
26 |
27 | .markdown bold {
28 | font-weight: bold;
29 | }
30 |
31 | .markdown code,
32 | .markdown pre {
33 | font-family: Menlo, monospace;
34 | background-color: rgba(255, 255, 255, 0.3);
35 | border-radius: 2px;
36 | padding-left: 2px;
37 | padding-right: 2px;
38 | }
39 |
40 | .markdown br {
41 | line-height: 2;
42 | }
43 |
--------------------------------------------------------------------------------
/src/styles/player.css:
--------------------------------------------------------------------------------
1 | html,
2 | body,
3 | #player-root,
4 | #player-root > [data-reactroot] {
5 | width: 100%;
6 | height: 100%;
7 | min-width: 0;
8 | min-height: 0;
9 | }
10 |
11 | #player-root {
12 | display: flex;
13 | font-family: 'helvetica';
14 | }
15 |
16 | :focus {
17 | outline: none;
18 | }
19 |
--------------------------------------------------------------------------------
/src/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html,
7 | body,
8 | div,
9 | span,
10 | applet,
11 | object,
12 | iframe,
13 | h1,
14 | h2,
15 | h3,
16 | h4,
17 | h5,
18 | h6,
19 | p,
20 | blockquote,
21 | pre,
22 | a,
23 | abbr,
24 | acronym,
25 | address,
26 | big,
27 | cite,
28 | code,
29 | del,
30 | dfn,
31 | em,
32 | img,
33 | ins,
34 | kbd,
35 | q,
36 | s,
37 | samp,
38 | small,
39 | strike,
40 | strong,
41 | sub,
42 | sup,
43 | tt,
44 | var,
45 | b,
46 | u,
47 | i,
48 | center,
49 | dl,
50 | dt,
51 | dd,
52 | ol,
53 | ul,
54 | li,
55 | fieldset,
56 | form,
57 | label,
58 | legend,
59 | table,
60 | caption,
61 | tbody,
62 | tfoot,
63 | thead,
64 | tr,
65 | th,
66 | td,
67 | article,
68 | aside,
69 | canvas,
70 | details,
71 | embed,
72 | figure,
73 | figcaption,
74 | footer,
75 | header,
76 | hgroup,
77 | menu,
78 | nav,
79 | output,
80 | ruby,
81 | section,
82 | summary,
83 | time,
84 | mark,
85 | audio,
86 | video {
87 | margin: 0;
88 | padding: 0;
89 | border: 0;
90 | font-size: 100%;
91 | font: inherit;
92 | vertical-align: baseline;
93 | }
94 | /* HTML5 display-role reset for older browsers */
95 | article,
96 | aside,
97 | details,
98 | figcaption,
99 | figure,
100 | footer,
101 | header,
102 | hgroup,
103 | menu,
104 | nav,
105 | section {
106 | display: block;
107 | }
108 | body {
109 | line-height: 1;
110 | }
111 | ol,
112 | ul {
113 | list-style: none;
114 | }
115 | blockquote,
116 | q {
117 | quotes: none;
118 | }
119 | blockquote:before,
120 | blockquote:after,
121 | q:before,
122 | q:after {
123 | content: '';
124 | content: none;
125 | }
126 | table {
127 | border-collapse: collapse;
128 | border-spacing: 0;
129 | }
130 |
--------------------------------------------------------------------------------
/src/types/Messages.ts:
--------------------------------------------------------------------------------
1 | export type SourceLocation = {
2 | file: string
3 | line: number
4 | column: number
5 | }
6 |
7 | type CommandBase = {
8 | id: string
9 | }
10 |
11 | export type ClearCommand = CommandBase & {
12 | command: 'clear'
13 | }
14 |
15 | export type LogVisibility = 'visible' | 'hidden'
16 |
17 | export type LogCommand = CommandBase & {
18 | command: 'log'
19 | location: SourceLocation
20 | data: unknown[]
21 | visibility: LogVisibility
22 | }
23 |
24 | export type ConsoleCommand = ClearCommand | LogCommand
25 |
26 | type MessageBase = {
27 | id: string
28 | codeVersion: number
29 | }
30 |
31 | export type ConsoleMessage = MessageBase & {
32 | type: 'console'
33 | payload: ConsoleCommand
34 | }
35 |
36 | export type ReadyMessage = MessageBase & {
37 | type: 'ready'
38 | }
39 |
40 | export type ErrorMessage = MessageBase & {
41 | type: 'error'
42 | payload: string
43 | }
44 |
45 | export type Message = ConsoleMessage | ReadyMessage | ErrorMessage
46 |
47 | export type MessageCallback = (message: Message) => void
48 |
--------------------------------------------------------------------------------
/src/types/globals.d.ts:
--------------------------------------------------------------------------------
1 | declare var __webpack_public_path__: string
2 |
--------------------------------------------------------------------------------
/src/types/react-native-web.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react-native-web' {
2 | export namespace AppRegistry {
3 | export function registerComponent(name: string, thunk: () => any): void
4 | export function getAppKeys(): string[]
5 | export function runApplication(
6 | name: string,
7 | options: { rootTag: HTMLElement }
8 | ): void
9 | }
10 | export namespace Dimensions {
11 | export function _update(): void
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/types/react.d.ts:
--------------------------------------------------------------------------------
1 | import { ReactElement } from 'react'
2 |
3 | declare module 'react' {
4 | function memo(
5 | Component: (props: A) => B
6 | ): (props: A) => ReactElement | null
7 | }
8 |
--------------------------------------------------------------------------------
/src/types/snarkdown.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'snarkdown' {
2 | const snarkdown: (source: string) => string
3 | export default snarkdown
4 | }
5 |
--------------------------------------------------------------------------------
/src/types/svg.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | const content: any
3 | export default content
4 | }
5 |
--------------------------------------------------------------------------------
/src/typescript-worker.ts:
--------------------------------------------------------------------------------
1 | import * as ts from 'typescript'
2 | import { entries } from './utils/Object'
3 | import type {
4 | TypeScriptErrorResponse,
5 | TypeScriptRequest,
6 | TypeScriptResponse,
7 | } from './utils/TypeScriptRequest'
8 | import LanguageServiceHost from './workers/typescript/LanguageServiceHost'
9 |
10 | import { system } from './workers/typescript/system'
11 |
12 | // Mock the host operating system
13 | ;(ts.sys as any) = system
14 |
15 | const context: Worker = self as any
16 |
17 | type Compiler = {
18 | host: LanguageServiceHost
19 | services: ts.LanguageService
20 | }
21 |
22 | // We store `compilerReady` as a regular boolean, since in the quickInfo case,
23 | // we want to immediately return (a delayed tooltip is not useful)
24 | let compilerReady = false
25 | let setCompiler: (compiler: Compiler) => void
26 | let compiler = new Promise((resolve) => {
27 | compilerReady = true
28 | setCompiler = resolve
29 | })
30 |
31 | function compile(
32 | services: ts.LanguageService,
33 | fileNames: string[]
34 | ): TypeScriptResponse {
35 | const createdFiles: Record = {}
36 |
37 | for (let fileName of fileNames) {
38 | let output = services.getEmitOutput(fileName)
39 |
40 | const error = getFirstError(fileName)
41 |
42 | if (error) return error
43 |
44 | output.outputFiles.forEach((o) => {
45 | createdFiles[o.name] = o.text
46 | })
47 | }
48 |
49 | return {
50 | type: 'code',
51 | files: createdFiles,
52 | }
53 |
54 | function getFirstError(
55 | fileName: string
56 | ): TypeScriptErrorResponse | undefined {
57 | let allDiagnostics = services
58 | .getCompilerOptionsDiagnostics()
59 | .concat(services.getSyntacticDiagnostics(fileName))
60 | .concat(services.getSemanticDiagnostics(fileName))
61 |
62 | for (let diagnostic of allDiagnostics) {
63 | let message = ts.flattenDiagnosticMessageText(
64 | diagnostic.messageText,
65 | '\n'
66 | )
67 |
68 | if (diagnostic.file) {
69 | let { line } = diagnostic.file.getLineAndCharacterOfPosition(
70 | diagnostic.start!
71 | )
72 |
73 | const formattedMessage = `TypeScript Compiler: ${message} (${line + 1})`
74 |
75 | return {
76 | type: 'error',
77 | error: {
78 | filename: diagnostic.file.fileName,
79 | message: formattedMessage,
80 | },
81 | }
82 | } else {
83 | console.log(`TypeScript Compiler: ${message}`)
84 | }
85 | }
86 | }
87 | }
88 |
89 | onmessage = function ({ data }) {
90 | if (!data || !data.id) return
91 |
92 | const { id, payload: command } = data as {
93 | id: string
94 | payload: TypeScriptRequest
95 | }
96 |
97 | switch (command.type) {
98 | case 'init': {
99 | const { libs, types, compilerOptions } = command
100 |
101 | const languageServiceHost = new LanguageServiceHost(compilerOptions)
102 |
103 | Promise.all([
104 | ...libs.map((lib: string) =>
105 | import(
106 | '!!raw-loader!../node_modules/typescript/lib/' + lib + '.d.ts'
107 | ).then((file) => {
108 | languageServiceHost.addFile(lib + '.d.ts', file.default)
109 | })
110 | ),
111 | ...types.map(({ name, url }: { name: string; url: string }) =>
112 | fetch(url)
113 | .then((data) => data.text())
114 | .then((code) => {
115 | languageServiceHost.addFile(name, code)
116 | })
117 | ),
118 | ])
119 | .catch((error) => {
120 | console.warn(error)
121 | console.warn('Failed to load TypeScript type definitions')
122 | })
123 | .then(() => {
124 | const languageService = ts.createLanguageService(
125 | languageServiceHost,
126 | ts.createDocumentRegistry()
127 | )
128 |
129 | setCompiler({
130 | host: languageServiceHost,
131 | services: languageService,
132 | })
133 | })
134 |
135 | return
136 | }
137 | case 'files': {
138 | compiler.then(({ host }) => {
139 | const { files } = command
140 |
141 | entries(files).forEach(([filename, code]) => {
142 | host.addFile(filename, code)
143 | })
144 | })
145 | return
146 | }
147 | case 'compile': {
148 | compiler.then(({ services }) => {
149 | const response = compile(services, [command.filename])
150 |
151 | context.postMessage({ id, payload: response })
152 | })
153 | return
154 | }
155 | case 'quickInfo': {
156 | if (!compilerReady) {
157 | context.postMessage({ id, payload: undefined })
158 |
159 | return
160 | }
161 |
162 | compiler.then(({ host, services }) => {
163 | const { filename, position } = command
164 |
165 | services.getProgram()
166 |
167 | if (!host.fileExists(filename)) {
168 | console.warn(`Can't get quickInfo, ${filename} doesn't exist yet`)
169 |
170 | context.postMessage({ id, payload: undefined })
171 |
172 | return
173 | }
174 |
175 | const quickInfo = services.getQuickInfoAtPosition(filename, position)
176 |
177 | context.postMessage({ id, payload: quickInfo })
178 | })
179 |
180 | return
181 | }
182 | }
183 | }
184 |
185 | export { LanguageServiceHost }
186 |
--------------------------------------------------------------------------------
/src/utils/BabelConsolePlugin.ts:
--------------------------------------------------------------------------------
1 | import type Babel from '@babel/core'
2 |
3 | // Add line & column info to console.log calls.
4 | //
5 | // console.log(arg) becomes console._rnwp_log("index.js", "8", "2", arg)
6 | export default ({
7 | types: t,
8 | }: typeof Babel): { visitor: Babel.Visitor } => {
9 | return {
10 | visitor: {
11 | CallExpression(path, options) {
12 | const optionsObject = { filename: '/', ...options }
13 |
14 | if (
15 | t.isMemberExpression(path.node.callee) &&
16 | t.isIdentifier(path.node.callee.object) &&
17 | t.isIdentifier(path.node.callee.property) &&
18 | path.node.callee.object.name === 'console' &&
19 | path.node.callee.property.name === 'log'
20 | ) {
21 | path.node.callee.property.name = '_rnwp_log'
22 |
23 | const strings = [
24 | optionsObject.filename.slice(1),
25 | path.node.loc!.end.line.toString(),
26 | path.node.loc!.start.column.toString(),
27 | 'visible',
28 | ]
29 |
30 | path.node.arguments = [
31 | ...strings.map((value) => t.stringLiteral(value)),
32 | ...path.node.arguments,
33 | ]
34 | }
35 | },
36 | },
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/utils/BabelExpressionLogPlugin.ts:
--------------------------------------------------------------------------------
1 | import type Babel from '@babel/core'
2 |
3 | // Wrap expression statements in instrumented console.log calls.
4 | //
5 | // 3 becomes console._rnwp_log("index.js", "8", "2", 3)
6 | export default ({
7 | types: t,
8 | }: typeof Babel): { visitor: Babel.Visitor } => {
9 | return {
10 | visitor: {
11 | ExpressionStatement(path, options) {
12 | const optionsObject = { filename: '/', ...options }
13 |
14 | // Don't instrument any calls to console.*
15 | if (
16 | t.isCallExpression(path.node.expression) &&
17 | t.isMemberExpression(path.node.expression.callee) &&
18 | t.isIdentifier(path.node.expression.callee.object) &&
19 | path.node.expression.callee.object.name === 'console'
20 | ) {
21 | return
22 | }
23 |
24 | const strings = [
25 | optionsObject.filename.slice(1),
26 | path.node.loc!.end.line.toString(),
27 | path.node.loc!.start.column.toString(),
28 | 'hidden',
29 | ]
30 |
31 | const call = t.callExpression(
32 | t.memberExpression(
33 | t.identifier('console'),
34 | t.identifier('_rnwp_log')
35 | ),
36 | [
37 | ...strings.map((value) => t.stringLiteral(value)),
38 | path.node.expression,
39 | ]
40 | )
41 |
42 | path.node.expression = call
43 | },
44 | },
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/utils/BabelInfiniteLoopPlugin.ts:
--------------------------------------------------------------------------------
1 | // Adapted from https://github.com/facebook/react/blob/480626a9e920d5e04194c793a828318102ea4ff4/scripts/babel/transform-prevent-infinite-loops.js
2 | // Based on https://repl.it/site/blog/infinite-loops.
3 |
4 | /**
5 | * Copyright (c) Facebook, Inc. and its affiliates.
6 | * Copyright (c) 2017, Amjad Masad
7 | *
8 | * This source code is licensed under the MIT license found in the
9 | * LICENSE file in the root directory of this source tree.
10 | */
11 |
12 | import type Babel from '@babel/core'
13 |
14 | const DEFAULT_MAX_ITERATIONS = 1000
15 |
16 | module.exports = ({ types: t }: typeof Babel) => {
17 | return {
18 | visitor: {
19 | 'WhileStatement|ForStatement|DoWhileStatement': (
20 | path: Babel.NodePath<
21 | | Babel.types.WhileStatement
22 | | Babel.types.ForStatement
23 | | Babel.types.DoWhileStatement
24 | >,
25 | options?: { opts?: { maxIterations?: number } }
26 | ) => {
27 | const maxIterations =
28 | options?.opts?.maxIterations ?? DEFAULT_MAX_ITERATIONS
29 |
30 | // An iterator incremented with each iteration
31 | const iterator = path.scope.parent.generateUidIdentifier('loopIt')
32 | const iteratorInit = t.numericLiteral(0)
33 | path.scope.parent.push({
34 | id: iterator,
35 | init: iteratorInit,
36 | })
37 |
38 | const guard = t.ifStatement(
39 | t.binaryExpression(
40 | '>',
41 | t.updateExpression('++', iterator, true),
42 | t.numericLiteral(maxIterations)
43 | ),
44 | t.throwStatement(
45 | t.newExpression(t.identifier('RangeError'), [
46 | t.stringLiteral(
47 | `Exceeded ${maxIterations} iterations, potential infinite loop.`
48 | ),
49 | ])
50 | )
51 | )
52 |
53 | const body = path.get('body')
54 |
55 | if (body.isBlockStatement()) {
56 | body.unshiftContainer('body', guard)
57 | } else {
58 | // No block statement e.g. `while (1) 1;`
59 | const statement = body.node
60 | body.replaceWith(t.blockStatement([guard, statement]))
61 | }
62 | },
63 | },
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/utils/BabelRequest.ts:
--------------------------------------------------------------------------------
1 | const babelWorker = new (require('../babel-worker.js') as any)()
2 | import { workerRequest } from './WorkerRequest'
3 |
4 | export type BabelRequest = {
5 | filename: string
6 | code: string
7 | options?: {
8 | retainLines?: boolean
9 | instrumentExpressionStatements?: boolean
10 | maxLoopIterations?: number
11 | }
12 | }
13 |
14 | type BabelResponseBase = {
15 | filename: string
16 | }
17 |
18 | export type BabelCodeResponse = BabelResponseBase & {
19 | type: 'code'
20 | code: string
21 | }
22 |
23 | export type BabelErrorResponse = BabelResponseBase & {
24 | type: 'error'
25 | error: {
26 | message: string
27 | }
28 | }
29 |
30 | export type BabelResponse = BabelCodeResponse | BabelErrorResponse
31 |
32 | export default function babelRequest(
33 | payload: BabelRequest
34 | ): Promise {
35 | return workerRequest(babelWorker, payload)
36 | }
37 |
--------------------------------------------------------------------------------
/src/utils/CSS.ts:
--------------------------------------------------------------------------------
1 | export const appendCSS = (document: Document, css: string) => {
2 | const textNode = document.createTextNode(css)
3 | const element = document.createElement('style')
4 | element.type = 'text/css'
5 | element.appendChild(textNode)
6 | document.head.appendChild(element)
7 | }
8 |
--------------------------------------------------------------------------------
/src/utils/CodeMirror.ts:
--------------------------------------------------------------------------------
1 | export const getOptions = (mode: string) => ({
2 | mode,
3 | value: '',
4 | theme: 'react',
5 | keyMap: 'sublime',
6 | indentUnit: 2,
7 | lineNumbers: true,
8 | dragDrop: false,
9 | showCursorWhenSelecting: true,
10 | autoCloseBrackets: true,
11 | matchTags: {
12 | bothTags: true,
13 | },
14 | autoRefresh: true,
15 | extraKeys: {
16 | Tab: 'indentMore',
17 | 'Cmd-/': (cm: CodeMirror.Editor) => {
18 | // Improve commenting within JSX (the default is HTML-style comments)
19 | if (mode === 'text/typescript-jsx') {
20 | ;(cm as any).toggleComment({
21 | lineComment: '//',
22 | })
23 | } else {
24 | cm.execCommand('toggleComment')
25 | }
26 | },
27 | },
28 | })
29 |
30 | export const requireAddons = () => {
31 | require('codemirror/mode/jsx/jsx')
32 | require('codemirror/keymap/sublime')
33 | require('codemirror/addon/fold/xml-fold') // required for matchtags
34 | require('codemirror/addon/edit/matchtags')
35 | require('codemirror/addon/edit/closebrackets')
36 | require('codemirror/addon/comment/comment')
37 | require('codemirror/addon/selection/active-line')
38 | require('codemirror/addon/display/autorefresh')
39 | }
40 |
--------------------------------------------------------------------------------
/src/utils/CodeMirrorTooltipAddon.ts:
--------------------------------------------------------------------------------
1 | import { prefixAndApply } from './Styles'
2 | import { CSSProperties } from 'react'
3 |
4 | export interface TooltipValue {
5 | getNode: (
6 | cm: CodeMirror.Editor,
7 | options: { index: number },
8 | callback: (node: HTMLElement) => void
9 | ) => void
10 | getContainerNode?: () => HTMLElement
11 | removeNode: (node: HTMLElement) => void
12 | style?: CSSProperties
13 | }
14 |
15 | type ExtendedToken = CodeMirror.Token & {
16 | id: string
17 | pos: CodeMirror.Position
18 | }
19 |
20 | export function tooltipAddon() {
21 | const CodeMirror = require('codemirror')
22 |
23 | const tooltipClassName = 'CodeMirror-tooltip'
24 |
25 | CodeMirror.defineOption('tooltip', null, function (
26 | cm: CodeMirror.Editor,
27 | value: TooltipValue
28 | ) {
29 | // Remove existing state
30 | if (cm.state.tooltip) {
31 | const state = cm.state.tooltip
32 | CodeMirror.off(cm.getWrapperElement(), 'mousemove', state.mousemove)
33 | CodeMirror.off(cm.getWrapperElement(), 'mouseout', state.mouseout)
34 | CodeMirror.off(window, 'scroll', state.windowScroll)
35 | cm.off('cursorActivity', reset)
36 | cm.off('scroll', reset)
37 | cm.state.tooltip = null
38 | }
39 |
40 | // Set up new state
41 | if (value && value.getNode) {
42 | const state = {
43 | mousemove: mousemove.bind(null, cm),
44 | mouseout: mouseout.bind(null, cm),
45 | windowScroll: reset.bind(null, cm),
46 | getNode: value.getNode,
47 | getContainerNode: value.getContainerNode || (() => document.body),
48 | removeNode: value.removeNode || (() => {}),
49 | style: value.style || {},
50 | }
51 | CodeMirror.on(cm.getWrapperElement(), 'mousemove', state.mousemove)
52 | CodeMirror.on(cm.getWrapperElement(), 'mouseout', state.mouseout)
53 | CodeMirror.on(window, 'scroll', state.windowScroll)
54 | cm.on('cursorActivity', reset)
55 | cm.on('scroll', reset)
56 | cm.state.tooltip = state
57 | }
58 | })
59 |
60 | function mousemove(cm: CodeMirror.Editor, event: MouseEvent) {
61 | var data = cm.state.tooltip
62 | if (event.buttons == null ? event.which : event.buttons) {
63 | delete data.coords
64 | } else {
65 | data.coords = { left: event.clientX, top: event.clientY }
66 | }
67 | scheduleUpdate(cm)
68 | }
69 |
70 | function mouseout(cm: CodeMirror.Editor, event: MouseEvent) {
71 | if (!cm.getWrapperElement().contains(event.relatedTarget as Node | null)) {
72 | var data = cm.state.tooltip
73 | delete data.coords
74 | scheduleUpdate(cm)
75 | }
76 | }
77 |
78 | function reset(cm: CodeMirror.Editor) {
79 | clear(cm)
80 | }
81 |
82 | function clear(cm: CodeMirror.Editor) {
83 | // Clear any existing timer
84 | clearTimeout(cm.state.tooltip.updateTimer)
85 |
86 | // Clear the hovered token
87 | delete cm.state.tooltip.token
88 |
89 | // Remove any existing tooltips
90 | if (cm.state.tooltip.removeTooltip) {
91 | cm.state.tooltip.removeTooltip()
92 | delete cm.state.tooltip.removeTooltip
93 | }
94 | }
95 |
96 | function scheduleUpdate(cm: CodeMirror.Editor) {
97 | const { coords } = cm.state.tooltip
98 |
99 | if (!coords) {
100 | clear(cm)
101 | return
102 | }
103 |
104 | const token = getToken(cm, coords)
105 |
106 | if (!token) {
107 | clear(cm)
108 | return
109 | }
110 |
111 | // Already scheduled
112 | if (cm.state.tooltip.token && token.id === cm.state.tooltip.token.id) {
113 | return
114 | }
115 |
116 | clear(cm)
117 |
118 | cm.state.tooltip.token = token
119 | cm.state.tooltip.updateTimer = setTimeout(() => {
120 | update(cm)
121 | }, 300)
122 | }
123 |
124 | /**
125 | * CodeMirror gives us the character that will be selected on click,
126 | * which is sometimes the next character after the one the mouse is
127 | * currently over. We want to disable that behavior, and always select.
128 | * the character under the mouse.
129 | */
130 | function snapToChar(pos: CodeMirror.Position) {
131 | if (pos.sticky === 'before') {
132 | return new CodeMirror.Pos(pos.line, pos.ch - 1)
133 | }
134 |
135 | return pos
136 | }
137 |
138 | function getToken(
139 | cm: CodeMirror.Editor,
140 | coords: { left: number; top: number }
141 | ): ExtendedToken | undefined {
142 | const pos = snapToChar(cm.coordsChar(coords))
143 |
144 | if (pos.ch <= 0 && pos.xRel <= 0) return
145 |
146 | const token = cm.getTokenAt(new CodeMirror.Pos(pos.line, pos.ch + 1))
147 |
148 | if (pos.ch > token.end || token.string.trim() === '') return
149 |
150 | return {
151 | ...token,
152 | pos,
153 | id: `${token.start}:${token.end}:${pos.line}`,
154 | }
155 | }
156 |
157 | function update(cm: CodeMirror.Editor) {
158 | const { coords, getNode } = cm.state.tooltip
159 |
160 | if (!coords) return
161 |
162 | const token = getToken(cm, coords)
163 |
164 | if (!token) return
165 |
166 | if (typeof getNode !== 'function') return
167 |
168 | const pos = token.pos
169 |
170 | const startPos = new CodeMirror.Pos(pos.line, token.start)
171 |
172 | getNode(
173 | cm,
174 | {
175 | index: cm.indexFromPos(pos) + 1,
176 | },
177 | (node: HTMLElement) => {
178 | if (!node) return
179 |
180 | cm.state.tooltip.removeTooltip = makeTooltip(
181 | cm,
182 | cm.charCoords(startPos),
183 | node
184 | )
185 | }
186 | )
187 | }
188 |
189 | // Tooltips
190 |
191 | function makeTooltip(
192 | cm: CodeMirror.Editor,
193 | coords: { top: number; left: number },
194 | child: HTMLElement
195 | ) {
196 | const tooltip = document.createElement('div')
197 | tooltip.className = tooltipClassName
198 | prefixAndApply(cm.state.tooltip.style, tooltip)
199 |
200 | tooltip.appendChild(child)
201 |
202 | const container = cm.state.tooltip.getContainerNode(cm)
203 | container.appendChild(tooltip)
204 |
205 | tooltip.style.left = `${coords.left}px`
206 | tooltip.style.top = `${Math.max(1, coords.top - tooltip.clientHeight)}px`
207 |
208 | return () => {
209 | // removeFromParent(tooltip)
210 |
211 | // There are still some scenarios where tooltips aren't cleaned up properly with removeFromParent.
212 | // For now, remove every tooltip from the DOM.
213 | removeAllTooltipNodes(cm.state.tooltip.removeNode)
214 | }
215 | }
216 |
217 | function removeFromParent(node: HTMLElement) {
218 | const parent = node && node.parentNode
219 |
220 | if (parent) {
221 | parent.removeChild(node)
222 | }
223 | }
224 |
225 | function removeAllTooltipNodes(callback: (element: ChildNode) => void) {
226 | document.querySelectorAll(`.${tooltipClassName}`).forEach((element) => {
227 | const parent = element.parentNode
228 |
229 | if (parent) {
230 | parent.removeChild(element)
231 |
232 | if (callback && element.firstChild) {
233 | callback(element.firstChild)
234 | }
235 | }
236 | })
237 | }
238 | }
239 |
--------------------------------------------------------------------------------
/src/utils/DOMCoding.ts:
--------------------------------------------------------------------------------
1 | // https://gist.github.com/sstur/7379870#file-dom-to-json-js
2 |
3 | type NodeObject = {
4 | nodeType: number
5 | tagName?: string
6 | nodeName?: string
7 | nodeValue?: string
8 | attributes?: [string, unknown][]
9 | childNodes?: unknown[]
10 | }
11 |
12 | let propFix: Record = { for: 'htmlFor', class: 'className' }
13 |
14 | /**
15 | * Serialize a DOM element as JSON
16 | */
17 | export function toJSON(node: HTMLElement) {
18 | let specialGetters = {
19 | style: (node: HTMLElement) => node.style.cssText,
20 | }
21 | let attrDefaultValues: Record = { style: '' }
22 | let obj: NodeObject = {
23 | nodeType: node.nodeType,
24 | }
25 | if (node.tagName) {
26 | obj.tagName = node.tagName.toLowerCase()
27 | } else if (node.nodeName) {
28 | obj.nodeName = node.nodeName
29 | }
30 | if (node.nodeValue) {
31 | obj.nodeValue = node.nodeValue
32 | }
33 | let attrs = node.attributes
34 | if (attrs) {
35 | let defaultValues = new Map()
36 | for (let i = 0; i < attrs.length; i++) {
37 | let name = attrs[i].nodeName
38 | defaultValues.set(name, attrDefaultValues[name])
39 | }
40 | // Add some special cases that might not be included by enumerating
41 | // attributes above. Note: this list is probably not exhaustive.
42 | switch (obj.tagName) {
43 | case 'input': {
44 | if (
45 | (node as any).type === 'checkbox' ||
46 | (node as any).type === 'radio'
47 | ) {
48 | defaultValues.set('checked', false)
49 | } else if ((node as any).type !== 'file') {
50 | // Don't store the value for a file input.
51 | defaultValues.set('value', '')
52 | }
53 | break
54 | }
55 | case 'option': {
56 | defaultValues.set('selected', false)
57 | break
58 | }
59 | case 'textarea': {
60 | defaultValues.set('value', '')
61 | break
62 | }
63 | }
64 | let arr: [string, unknown][] = []
65 | for (let [name, defaultValue] of defaultValues) {
66 | let propName = propFix[name] || name
67 | let specialGetter = (specialGetters as any)[propName]
68 | let value = specialGetter ? specialGetter(node) : (node as any)[propName]
69 | if (value !== defaultValue) {
70 | arr.push([name, value])
71 | }
72 | }
73 | if (arr.length) {
74 | obj.attributes = arr
75 | }
76 | }
77 | let childNodes = node.childNodes
78 | // Don't process children for a textarea since we used `value` above.
79 | if (obj.tagName !== 'textarea' && childNodes && childNodes.length) {
80 | let arr: unknown[] = (obj.childNodes = [])
81 | for (let i = 0; i < childNodes.length; i++) {
82 | arr[i] = toJSON(childNodes[i] as any)
83 | }
84 | }
85 | return obj
86 | }
87 |
88 | /**
89 | * Deserialize a DOM element
90 | */
91 | export function toDOM(
92 | input: any
93 | ): HTMLElement | Text | Comment | DocumentFragment {
94 | let obj: NodeObject = typeof input === 'string' ? JSON.parse(input) : input
95 | let node
96 | let nodeType = obj.nodeType
97 | switch (nodeType) {
98 | // ELEMENT_NODE
99 | case 1: {
100 | node = document.createElement(obj.tagName!)
101 | if (obj.attributes) {
102 | for (let [attrName, value] of obj.attributes) {
103 | let propName = propFix[attrName] || attrName
104 | // Note: this will throw if setting the value of an input[type=file]
105 | ;(node as any)[propName] = value
106 | }
107 | }
108 | break
109 | }
110 | // TEXT_NODE
111 | case 3: {
112 | return document.createTextNode(obj.nodeValue!)
113 | }
114 | // COMMENT_NODE
115 | case 8: {
116 | return document.createComment(obj.nodeValue!)
117 | }
118 | // DOCUMENT_FRAGMENT_NODE
119 | case 11: {
120 | node = document.createDocumentFragment()
121 | break
122 | }
123 | default: {
124 | // Default to an empty fragment node.
125 | return document.createDocumentFragment()
126 | }
127 | }
128 | if (obj.childNodes && obj.childNodes.length) {
129 | for (let childNode of obj.childNodes) {
130 | node.appendChild(toDOM(childNode))
131 | }
132 | }
133 | return node
134 | }
135 |
--------------------------------------------------------------------------------
/src/utils/Diff.ts:
--------------------------------------------------------------------------------
1 | import type * as Diff from 'diff'
2 |
3 | export type DiffRange = [number, number]
4 |
5 | export type ExtendedChange = Diff.Change & { ranges: DiffRange[] }
6 |
7 | export type DiffResult = { value: string; ranges: DiffRange[] }
8 |
9 | export type LineDiff = { added: DiffRange[] }
10 |
11 | const newlineRegex = /\r\n|\n|\r/g
12 |
13 | export default function changedRanges(
14 | originalText: string,
15 | newText: string
16 | ): LineDiff {
17 | if (typeof navigator === 'undefined') return { added: [] }
18 |
19 | const diff = require('diff') as typeof Diff
20 |
21 | function diffLines(originalText: string, newText: string) {
22 | const lineDiff = diff.diffLines(originalText, newText, {
23 | newlineIsToken: true,
24 | })
25 |
26 | const result = lineDiff.reduce(
27 | (result: DiffResult, change: Diff.Change) => {
28 | if (change.removed) return result
29 |
30 | let { ranges, value } = result
31 |
32 | const beforeLines = value.split(newlineRegex)
33 |
34 | value += change.value
35 |
36 | const afterLines = value.split(newlineRegex)
37 |
38 | let beforeLineCount = beforeLines.length - 1
39 | let afterLineCount = afterLines.length - 1
40 |
41 | // If we start with a non-empty line that doesn't change, don't count it
42 | if (
43 | beforeLines.length > 0 &&
44 | beforeLines[beforeLineCount].trim() != '' &&
45 | beforeLines[beforeLineCount] === afterLines[beforeLineCount]
46 | ) {
47 | beforeLineCount += 1
48 | }
49 |
50 | // If we end with an empty line, don't count it
51 | if (afterLines.length > 0 && afterLines[afterLineCount].trim() == '') {
52 | afterLineCount -= 1
53 | }
54 |
55 | if (change.added) {
56 | ranges.push([beforeLineCount, afterLineCount])
57 | }
58 |
59 | return { value, ranges }
60 | },
61 | { value: '', ranges: [] }
62 | )
63 |
64 | return { added: result.ranges }
65 | }
66 |
67 | return diffLines(originalText, newText)
68 | }
69 |
--------------------------------------------------------------------------------
/src/utils/ErrorMessage.ts:
--------------------------------------------------------------------------------
1 | import { PublicError } from '../components/workspace/Workspace'
2 |
3 | type MessageHandler = [
4 | string,
5 | (message: string) => { summary: string; description: string }
6 | ]
7 |
8 | const messages: MessageHandler[] = [
9 | // IE 10
10 | [
11 | `TypeError: Unable to get property 'pos' of undefined or null reference`,
12 | (message) => {
13 | return {
14 | summary: `This playground isn't supported for your web browser.`,
15 | description: `Please use the latest Google Chrome, Safari, Firefox, or Edge to use this playground.`,
16 | }
17 | },
18 | ],
19 | [
20 | `TypeError: inst.render is not a function`,
21 | (message) => {
22 | return {
23 | summary: `Invalid component or undefined render method.`,
24 | description: `No 'render' method found on the returned component instance: you may have forgotten to define 'render', returned null/false from a stateless component, or tried to render an element whose type is a function that isn't a React component.`,
25 | }
26 | },
27 | ],
28 | [
29 | `Invariant Violation: Element type is invalid`,
30 | (message) => {
31 | const ownerName = message.match(/`(.*)`/)
32 | const byOwner = ownerName ? ` by ${ownerName[0]} ` : ' '
33 | return {
34 | summary: `The element rendered${byOwner}is either invalid, or can't run on the web.`,
35 | description: `Every element must be an instance of a React Class, instantiated either with React.createElement or using a JSX expression like ''. Additionally, some components aren’t available to the playground, and thus will only run on a real native device or emulator.`,
36 | }
37 | },
38 | ],
39 | ]
40 |
41 | const defaultDescription = `The playground encountered an error. When you fix the error, the playground will automatically re-run your code.`
42 |
43 | export const getErrorDetails = (originalMessage: string): PublicError => {
44 | const firstLine = originalMessage.split('\n')[0]
45 | const errorLineNumber = firstLine.match(/\((\d+)/)
46 |
47 | const details = {
48 | lineNumber:
49 | errorLineNumber !== null ? parseInt(errorLineNumber[1]) - 1 : undefined,
50 | summary: firstLine,
51 | description: defaultDescription,
52 | errorMessage: originalMessage,
53 | }
54 |
55 | for (let i = 0; i < messages.length; i++) {
56 | const [predicate, enhancer] = messages[i]
57 | if (originalMessage.match(predicate)) {
58 | return {
59 | ...details,
60 | ...enhancer(originalMessage),
61 | }
62 | }
63 | }
64 |
65 | return details
66 | }
67 |
--------------------------------------------------------------------------------
/src/utils/ExtendedJSON.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 |
4 | import * as DOMCoding from './DOMCoding'
5 |
6 | export const undefinedMarker = '__rnwp_undefined__'
7 | export const functionMarker = '__rnwp_function__'
8 | export const domNodeMarker = '__rnwp_dom_node__'
9 |
10 | // Parse, preserving values that can't be represented in JSON
11 | export function parse(json: string): unknown {
12 | return JSON.parse(json, (key, value) => {
13 | if (typeof value === 'string') {
14 | if (value.startsWith(undefinedMarker)) {
15 | return undefined
16 | } else if (value.startsWith(functionMarker)) {
17 | return value.slice(functionMarker.length)
18 | } else if (value.startsWith(domNodeMarker)) {
19 | return DOMCoding.toDOM(JSON.parse(value.slice(domNodeMarker.length)))
20 | }
21 | }
22 |
23 | return value
24 | })
25 | }
26 |
27 | // Stringify, preserving values that can't be represented in JSON
28 | export function stringify(js: unknown) {
29 | return JSON.stringify(js, (key, value) => {
30 | if (value instanceof HTMLElement) {
31 | return domNodeMarker + JSON.stringify(DOMCoding.toJSON(value))
32 | }
33 |
34 | if (React.isValidElement(value)) {
35 | const host = document.createElement('span')
36 | ReactDOM.render(value, host)
37 | return domNodeMarker + JSON.stringify(DOMCoding.toJSON(host))
38 | }
39 |
40 | switch (typeof value) {
41 | case 'undefined':
42 | return undefinedMarker
43 | case 'function':
44 | return functionMarker + value.toString()
45 | default:
46 | return value
47 | }
48 | })
49 | }
50 |
--------------------------------------------------------------------------------
/src/utils/HashString.ts:
--------------------------------------------------------------------------------
1 | import { decode, encode, QueryParameters } from './queryString'
2 |
3 | export const getHashString = (): QueryParameters =>
4 | decode(window.location.hash.substring(1))
5 |
6 | export const buildHashString = (params: QueryParameters = {}): string =>
7 | '#' + encode(params)
8 |
--------------------------------------------------------------------------------
/src/utils/MockDOM.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * We create a fake DOM for Python, since it runs in a web worker for performance.
3 | * Fortunately only a small amount of APIs are needed to support figure generation.
4 | * A different approach (e.g. bridging API calls into the main thread) might work
5 | * better for complex DOM manipulation.
6 | */
7 |
8 | const context: Worker = self as any
9 |
10 | const elements: DOMElement[] = []
11 |
12 | // Store this outside the element, so we don't convert to/from python.
13 | // I'm not 100% sure this happens, but it seems like it does under some circumstances,
14 | // e.g. maybe when printing or calling repr
15 | let savedImageData: Record = {}
16 |
17 | class TokenList {
18 | add() {}
19 | }
20 |
21 | class DOMElement {
22 | id?: string
23 | type: string
24 | children: DOMElement[] = []
25 | attributes: Record = {}
26 | style: Record = {}
27 | classList = new TokenList()
28 |
29 | constructor(type: string) {
30 | this.type = type
31 | }
32 |
33 | appendChild(child: DOMElement) {
34 | this.children.push(child)
35 | }
36 |
37 | setAttribute(key: string, value: unknown) {
38 | this.attributes[key] = value
39 | }
40 |
41 | addEventListener() {}
42 | scrollIntoView() {}
43 | }
44 |
45 | class DOMHeadElement extends DOMElement {
46 | constructor() {
47 | super('head')
48 | }
49 |
50 | appendChild(child: DOMElement) {
51 | super.appendChild(child)
52 |
53 | if (child.type === 'script') {
54 | try {
55 | ;(context as any).importScripts((child as any).src)
56 | ;(child as any).onload()
57 | } catch (e) {
58 | ;(child as any).onerror()
59 | }
60 | }
61 | }
62 | }
63 |
64 | let contextId = 0
65 |
66 | class Context2D {
67 | _id = `${contextId++}`
68 | clearRect() {}
69 | strokeRect() {}
70 | setLineDash() {}
71 | putImageData(imageData: ImageData) {
72 | savedImageData[this._id] = imageData
73 | }
74 | getImageData() {
75 | return savedImageData[this._id]
76 | }
77 | }
78 |
79 | export class DOMCanvasElement extends DOMElement {
80 | constructor() {
81 | super('canvas')
82 | }
83 |
84 | getContext() {
85 | return this._context
86 | }
87 |
88 | private _context: Context2D = new Context2D()
89 | }
90 |
91 | export const document = {
92 | head: new DOMHeadElement(),
93 | createTextNode: (text: string) => text,
94 | createElement: (type: string) => {
95 | let element: DOMElement
96 |
97 | switch (type) {
98 | case 'canvas':
99 | element = new DOMCanvasElement()
100 | break
101 | default:
102 | element = new DOMElement(type)
103 | }
104 |
105 | elements.push(element)
106 | return element
107 | },
108 | getElementById: (id: string): DOMElement | undefined => {
109 | return elements.find((e) => e.id === id)
110 | },
111 | }
112 |
113 | // Make sure unused data is garbage collected
114 | export function reset() {
115 | elements.length = 0
116 | savedImageData = {}
117 | }
118 |
--------------------------------------------------------------------------------
/src/utils/Networking.ts:
--------------------------------------------------------------------------------
1 | // Fetch isn't supported in older versions Safari.
2 | // Fetch is large. Copy just the relevant bit.
3 |
4 | // https://github.com/github/fetch (MIT)
5 | export const get = (url: string): Promise => {
6 | return new Promise((resolve, reject) => {
7 | var xhr = new XMLHttpRequest()
8 |
9 | xhr.onload = () => {
10 | if (xhr.status >= 200 && xhr.status < 300) {
11 | resolve(xhr.response)
12 | } else {
13 | reject(xhr)
14 | }
15 | }
16 |
17 | xhr.onerror = xhr.ontimeout = () => {
18 | reject(new TypeError('Network request failed'))
19 | }
20 |
21 | xhr.open('GET', url, true)
22 |
23 | xhr.send()
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/src/utils/Object.ts:
--------------------------------------------------------------------------------
1 | export function hasOwnProperty(
2 | obj: O,
3 | key: K
4 | ): obj is O & { [key in K]: unknown } {
5 | return Object.prototype.hasOwnProperty.call(obj, key)
6 | }
7 |
8 | function isEnumerable(obj: object, key: PropertyKey) {
9 | return Object.prototype.propertyIsEnumerable.call(obj, key)
10 | }
11 |
12 | export function fromEntries(
13 | entries: Iterable
14 | ): { [k: string]: T } {
15 | return [...entries].reduce((obj: Record, [key, val]) => {
16 | obj[key as any] = val
17 | return obj
18 | }, {})
19 | }
20 |
21 | export function entries(
22 | obj: { [s: string]: T } | ArrayLike
23 | ): [string, T][] {
24 | if (obj == null) {
25 | throw new TypeError('Cannot convert undefined or null to object')
26 | }
27 |
28 | const pairs: [string, T][] = []
29 |
30 | for (let key in obj) {
31 | if (hasOwnProperty(obj, key) && isEnumerable(obj, key)) {
32 | pairs.push([key, obj[key]])
33 | }
34 | }
35 |
36 | return pairs
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/Panes.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from 'react'
2 | import { ExternalModule } from '../components/player/VendorComponents'
3 | import { PublicOptions } from './options'
4 |
5 | export interface ConsoleOptions {
6 | showFileName: boolean
7 | showLineNumber: boolean
8 | renderReactElements: boolean
9 | }
10 |
11 | export interface EmbeddedPaneOptions {
12 | visible: boolean
13 | maximized: boolean
14 | collapsible: boolean
15 | }
16 |
17 | export type EmbeddedConsoleOptions = ConsoleOptions & EmbeddedPaneOptions
18 |
19 | export type PaneBaseOptions = {
20 | id: string
21 | title?: string
22 | style?: CSSProperties
23 | }
24 |
25 | export type StackPaneOptions = PaneBaseOptions & {
26 | type: 'stack'
27 | children: PaneOptions[]
28 | }
29 |
30 | export type EditorPaneOptions = PaneBaseOptions & {
31 | type: 'editor'
32 | fileList?: 'tabs' | 'sidebar'
33 | }
34 |
35 | export type TranspilerPaneOptions = PaneBaseOptions & {
36 | type: 'transpiler'
37 | }
38 |
39 | export type PlayerPaneOptions = PaneBaseOptions & {
40 | type: 'player'
41 | platform?: string
42 | scale?: number
43 | width?: number
44 | assetRoot?: string
45 | modules?: ExternalModule[]
46 | styleSheet?: string
47 | css?: string
48 | prelude?: string
49 | statusBarHeight?: number
50 | statusBarColor?: string
51 | console?: EmbeddedConsoleOptions
52 | reloadable?: boolean
53 | }
54 |
55 | export type WorkspacesPaneOptions = PaneBaseOptions & {
56 | type: 'workspaces'
57 | }
58 |
59 | export type ConsolePaneOptions = PaneBaseOptions &
60 | ConsoleOptions & {
61 | type: 'console'
62 | }
63 |
64 | export type PaneOptions =
65 | | StackPaneOptions
66 | | EditorPaneOptions
67 | | TranspilerPaneOptions
68 | | PlayerPaneOptions
69 | | WorkspacesPaneOptions
70 | | ConsolePaneOptions
71 |
72 | export type PaneShorthand = PaneOptions['type']
73 |
74 | export const containsPane = (panes: PaneOptions[], target: string): boolean =>
75 | panes.some((pane: PaneOptions) => {
76 | if (pane.type === target) return true
77 |
78 | return pane.type === 'stack' && pane.children
79 | ? containsPane(pane.children, target)
80 | : false
81 | })
82 |
83 | let initialId = 0
84 | const getNextId = () => `${initialId++}`
85 |
86 | /**
87 | * Turn panes into objects, and assign a unique id to each.
88 | */
89 | export const normalizePane = (
90 | pane: PaneShorthand | PaneOptions,
91 | publicOptions: PublicOptions
92 | ): PaneOptions => {
93 | let options =
94 | typeof pane === 'string' ? ({ type: pane } as PaneOptions) : pane
95 |
96 | options.id = options.id || getNextId()
97 |
98 | if (options.type === 'stack') {
99 | return {
100 | ...options,
101 | children: options.children.map((child) =>
102 | normalizePane(child, publicOptions)
103 | ),
104 | }
105 | }
106 |
107 | if (options.type === 'editor' && publicOptions.title && !options.title) {
108 | return {
109 | ...options,
110 | title: publicOptions.title,
111 | }
112 | }
113 |
114 | if (
115 | options.type === 'player' &&
116 | publicOptions.modules &&
117 | publicOptions.modules.length > 0
118 | ) {
119 | return {
120 | ...options,
121 | modules: [...(options.modules || []), ...publicOptions.modules],
122 | }
123 | }
124 |
125 | return options
126 | }
127 |
--------------------------------------------------------------------------------
/src/utils/PlayerUtils.ts:
--------------------------------------------------------------------------------
1 | import { PlayerStyles } from '../player'
2 | import { prefixAndApply } from './Styles'
3 |
4 | export function createAppLayout(document: Document, styles: PlayerStyles) {
5 | const mount = document.getElementById('player-root') as HTMLDivElement
6 | prefixAndApply(styles.playerRoot, mount)
7 |
8 | const wrapperElement = document.createElement('div')
9 | prefixAndApply(styles.playerWrapper, wrapperElement)
10 |
11 | mount.appendChild(wrapperElement)
12 |
13 | const appElement = document.createElement('div')
14 | appElement.id = 'app'
15 | prefixAndApply(styles.playerApp, appElement)
16 |
17 | wrapperElement.appendChild(appElement)
18 |
19 | return { wrapperElement, appElement }
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/Styles.ts:
--------------------------------------------------------------------------------
1 | import { prefix as prefixStyle } from 'inline-style-prefixer'
2 | import type { CSSProperties } from 'react'
3 |
4 | type Style = CSSProperties & { _prefixed?: boolean }
5 |
6 | const prefixMarker = '_prefixed'
7 |
8 | // Add a special marker to avoid prefixing multiple times
9 | const addPrefixMarker = (style: CSSProperties): void => {
10 | Object.defineProperty(style, prefixMarker, {
11 | enumerable: false,
12 | value: true,
13 | })
14 | }
15 |
16 | export const prefix = (style: Style) => {
17 | if (style._prefixed === true) return style
18 |
19 | const prefixedStyle = prefixStyle({ ...style })
20 |
21 | // Display becomes an array - we just shouldn't prefix it
22 | if (style.display) {
23 | prefixedStyle.display = style.display
24 | }
25 |
26 | addPrefixMarker(prefixedStyle)
27 |
28 | return prefixedStyle
29 | }
30 |
31 | /**
32 | * Merge styles.
33 | *
34 | * If only a single style is passed, it may be returned directly
35 | * to avoid allocating another style and potentially hurting memoization in components.
36 | *
37 | * @param styles
38 | */
39 | export const mergeStyles = (...styles: (Style | undefined)[]): Style => {
40 | // Reuse the original style if possible
41 | if (styles.length === 1 && styles[0]?._prefixed === true) {
42 | return styles[0]
43 | }
44 |
45 | const filtered = styles.filter((style) => !!style) as Style[]
46 | const prefixedStyles = filtered.map(prefix)
47 | const prefixedStyle = Object.assign({}, ...prefixedStyles)
48 |
49 | addPrefixMarker(prefixedStyle)
50 |
51 | return prefixedStyle
52 | }
53 |
54 | export const prefixObject = (
55 | styles: T
56 | ): T => {
57 | const output: any = {}
58 |
59 | for (let key in styles) {
60 | if (styles.hasOwnProperty(key)) {
61 | output[key] = prefix(styles[key])
62 | }
63 | }
64 |
65 | return output
66 | }
67 |
68 | export const prefixAndApply = (
69 | style: Style,
70 | node: ElementCSSInlineStyle
71 | ): void => {
72 | const prefixed = prefix(style)
73 |
74 | for (let key in prefixed) {
75 | node.style[key as any] = (prefixed as any)[key]
76 | }
77 | }
78 |
79 | export const columnStyle = prefix({
80 | flex: '1',
81 | display: 'flex',
82 | flexDirection: 'column',
83 | alignItems: 'stretch',
84 | minWidth: 0,
85 | minHeight: 0,
86 | overflow: 'hidden', // Clip box shadows
87 | position: 'relative',
88 | })
89 |
90 | export const rowStyle = prefix({
91 | flex: '1',
92 | display: 'flex',
93 | flexDirection: 'row',
94 | alignItems: 'stretch',
95 | minWidth: 0,
96 | minHeight: 0,
97 | overflow: 'hidden', // Clip box shadows
98 | position: 'relative',
99 | })
100 |
--------------------------------------------------------------------------------
/src/utils/Tab.ts:
--------------------------------------------------------------------------------
1 | export interface Tab {
2 | index: number
3 | title: string
4 | changed: boolean
5 | }
6 | export const compareTabs = (a: Tab, b: Tab) => a.index === b.index
7 | export const getTabTitle = (tab: Tab) => tab.title
8 | export const getTabChanged = (tab: Tab) => tab.changed
9 |
--------------------------------------------------------------------------------
/src/utils/TypeScriptDefaultConfig.ts:
--------------------------------------------------------------------------------
1 | import type * as ts from 'typescript'
2 |
3 | // We don't want to accidentally import from ts and include it in the bundle,
4 | // so we use a number instead of an enum for some options.
5 | // TODO: Test if using just the enum increases bundle size.
6 | const compilerOptions: ts.CompilerOptions = {
7 | target: 1, // ts.ScriptTarget.ES5,
8 | module: 1, // ts.ModuleKind.CommonJS,
9 | strict: true,
10 | strictNullChecks: true,
11 | strictFunctionTypes: true,
12 | strictPropertyInitialization: true,
13 | strictBindCallApply: true,
14 | noImplicitThis: true,
15 | noImplicitAny: true,
16 | alwaysStrict: true,
17 | esModuleInterop: true,
18 | experimentalDecorators: true,
19 | emitDecoratorMetadata: true,
20 | incremental: true,
21 | tsBuildInfoFile: '/.tsbuildinfo',
22 | jsx: 2, // ts.JsxEmit.React,
23 | }
24 |
25 | export default compilerOptions
26 |
--------------------------------------------------------------------------------
/src/utils/TypeScriptDefaultLibs.ts:
--------------------------------------------------------------------------------
1 | export default [
2 | 'lib',
3 | 'lib.es5',
4 | 'lib.es2015',
5 | 'lib.es2015.collection',
6 | 'lib.es2015.core',
7 | 'lib.es2015.generator',
8 | 'lib.es2015.iterable',
9 | 'lib.es2015.promise',
10 | 'lib.es2015.proxy',
11 | 'lib.es2015.reflect',
12 | 'lib.es2015.symbol',
13 | 'lib.es2015.symbol.wellknown',
14 | 'lib.es2016.array.include',
15 | // 'lib.es2017.intl',
16 | 'lib.es2017.object',
17 | // 'lib.es2017.sharedmemory',
18 | 'lib.es2017.string',
19 | // 'lib.es2017.typedarrays',
20 | // 'lib.es2018.asyncgenerator',
21 | // 'lib.es2018.asynciterable',
22 | // 'lib.es2018.intl',
23 | // 'lib.es2018.promise',
24 | // 'lib.es2018.regexp',
25 | // 'lib.es2019.array',
26 | 'lib.es2019.object',
27 | // 'lib.es2019.string',
28 | // 'lib.es2019.symbol',
29 | // 'lib.es2020.bigint',
30 | // 'lib.es2020.promise',
31 | // 'lib.es2020.string',
32 | // 'lib.es2020.symbol.wellknown',
33 | 'lib.dom',
34 | 'lib.scripthost', // To silence tsc warning
35 | 'lib.webworker.importscripts', // To silence tsc warning
36 | ]
37 |
--------------------------------------------------------------------------------
/src/utils/TypeScriptRequest.ts:
--------------------------------------------------------------------------------
1 | import type * as ts from 'typescript'
2 | import { workerRequest } from './WorkerRequest'
3 |
4 | export type TypeScriptInitRequest = {
5 | type: 'init'
6 | libs: string[]
7 | types: { name: string; url: string }[]
8 | compilerOptions: ts.CompilerOptions
9 | }
10 |
11 | export type TypeScriptFileRequest = {
12 | type: 'files'
13 | files: Record
14 | }
15 |
16 | export type TypeScriptQuickInfoRequest = {
17 | type: 'quickInfo'
18 | filename: string
19 | position: number
20 | }
21 |
22 | export type TypeScriptCompileRequest = {
23 | type: 'compile'
24 | filename: string
25 | }
26 |
27 | export type TypeScriptRequest =
28 | | TypeScriptInitRequest
29 | | TypeScriptFileRequest
30 | | TypeScriptQuickInfoRequest
31 | | TypeScriptCompileRequest
32 |
33 | export type TypeScriptCodeResponse = {
34 | type: 'code'
35 | files: Record
36 | }
37 |
38 | export type TypeScriptErrorResponse = {
39 | type: 'error'
40 | error: {
41 | filename: string
42 | message: string
43 | }
44 | }
45 |
46 | export type TypeScriptResponse =
47 | | TypeScriptCodeResponse
48 | | TypeScriptErrorResponse
49 |
50 | let typeScriptWorker: Promise | undefined
51 |
52 | function typeScriptRequest(
53 | payload: TypeScriptQuickInfoRequest
54 | ): Promise
55 | function typeScriptRequest(payload: TypeScriptInitRequest): Promise
56 | function typeScriptRequest(payload: TypeScriptFileRequest): Promise
57 | function typeScriptRequest(
58 | payload: TypeScriptCompileRequest
59 | ): Promise
60 | function typeScriptRequest(payload: TypeScriptRequest): Promise {
61 | if (!typeScriptWorker) {
62 | typeScriptWorker = import('../typescript-worker').then((worker) =>
63 | (worker as any).default()
64 | )
65 | }
66 |
67 | return typeScriptWorker.then((worker: Worker) =>
68 | workerRequest(worker, payload)
69 | )
70 | }
71 |
72 | export default typeScriptRequest
73 |
--------------------------------------------------------------------------------
/src/utils/WorkerRequest.ts:
--------------------------------------------------------------------------------
1 | let requestId = 0
2 | const nextRequestId = () => `${requestId++}`
3 |
4 | interface WorkerRequestMessageEvent extends MessageEvent {
5 | data: {
6 | id: string
7 | payload: T
8 | }
9 | }
10 |
11 | export function workerRequest(
12 | worker: Worker,
13 | payload: Request
14 | ): Promise {
15 | return new Promise((resolve, reject) => {
16 | try {
17 | const id = nextRequestId()
18 |
19 | const handleMessage = ({ data }: WorkerRequestMessageEvent) => {
20 | if (data && data.id === id) {
21 | worker.removeEventListener('message', handleMessage)
22 | return resolve(data.payload)
23 | }
24 | }
25 |
26 | worker.addEventListener('message', handleMessage)
27 | worker.postMessage({ id, payload })
28 | } catch (error) {
29 | return reject(error)
30 | }
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/formatError.ts:
--------------------------------------------------------------------------------
1 | export default function formatError(e: Error, prefixLineCount: number): string {
2 | let message = `${e.name}: ${e.message}`
3 | let line = null
4 |
5 | // Safari
6 | if ((e as any).line != null) {
7 | line = (e as any).line + 1
8 |
9 | // FF
10 | } else if ((e as any).lineNumber != null) {
11 | line = (e as any).lineNumber
12 |
13 | // Chrome
14 | } else if (e.stack) {
15 | const matched = e.stack.match(/:(\d+)/)
16 | if (matched) {
17 | line = parseInt(matched[1])
18 | }
19 | }
20 |
21 | if (typeof line === 'number') {
22 | line -= prefixLineCount
23 | message = `${message} (${line})`
24 | }
25 |
26 | return message
27 | }
28 |
--------------------------------------------------------------------------------
/src/utils/hasProperty.ts:
--------------------------------------------------------------------------------
1 | export default function hasProperty(
2 | obj: O,
3 | propKey: K
4 | ): obj is O & { [key in K]: unknown } {
5 | return propKey in obj
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/path.ts:
--------------------------------------------------------------------------------
1 | export const sep = '/'
2 |
3 | export function normalize(filename: string) {
4 | const segments = filename.split(sep)
5 |
6 | let i = 0
7 | let length = segments.length - 1
8 |
9 | while (i < length) {
10 | if (segments[i] === '.') {
11 | segments.splice(i, 1)
12 | length--
13 | } else if (segments[i] === '..' && i !== 0) {
14 | segments.splice(i - 1, 2)
15 | length -= 2
16 | } else {
17 | i++
18 | }
19 | }
20 |
21 | return segments.join(sep)
22 | }
23 |
24 | export function join(...parts: string[]) {
25 | return normalize(parts.filter((part) => !!part).join(sep))
26 | }
27 |
28 | export function extname(filename: string) {
29 | const index = filename.lastIndexOf('.')
30 | return index !== -1 ? filename.slice(index) : filename
31 | }
32 |
33 | export function basename(filename: string, extname?: string) {
34 | if (extname && filename.endsWith(extname)) {
35 | filename = filename.slice(0, -extname.length)
36 | }
37 |
38 | return filename.slice(filename.lastIndexOf(sep) + 1)
39 | }
40 |
41 | export function dirname(filename: string) {
42 | if (filename === '') return '.'
43 | if (filename === sep) return sep
44 |
45 | let base = basename(filename)
46 | return filename.slice(0, -(base.length + 1))
47 | }
48 |
--------------------------------------------------------------------------------
/src/utils/playerCommunication.ts:
--------------------------------------------------------------------------------
1 | import { isValidElement } from 'react'
2 | import ReactDOM from 'react-dom'
3 | import {
4 | consoleClear,
5 | consoleLog,
6 | consoleLogRNWP,
7 | ConsoleProxy,
8 | } from '../components/player/ConsoleProxy'
9 | import { EvaluationContext } from '../environments/IEnvironment'
10 | import { Message } from '../types/Messages'
11 | import * as ExtendedJSON from './ExtendedJSON'
12 |
13 | function post(message: Message) {
14 | try {
15 | parent.postMessage(ExtendedJSON.stringify(message), '*')
16 | } catch {}
17 | }
18 |
19 | export function sendMessage(sharedEnvironment: boolean, message: Message) {
20 | if (sharedEnvironment) {
21 | enhanceConsoleLogs(message)
22 | parent.__message(message)
23 | } else {
24 | post(message)
25 | }
26 | }
27 |
28 | export function sendError(
29 | id: string,
30 | codeVersion: number,
31 | errorMessage: string
32 | ) {
33 | post({ id, codeVersion, type: 'error', payload: errorMessage })
34 | }
35 |
36 | export function createWindowErrorHandler({
37 | codeVersion,
38 | id,
39 | prefixLineCount,
40 | }: {
41 | codeVersion: number
42 | id: string
43 | prefixLineCount: number
44 | }) {
45 | return (message: Event | string, _?: string, line?: number) => {
46 | const editorLine = (line || 0) - prefixLineCount
47 | sendError(id, codeVersion, `${message} (${editorLine})`)
48 | return true
49 | }
50 | }
51 |
52 | export function bindConsoleLogMethods(options: {
53 | codeVersion: number
54 | consoleProxy: ConsoleProxy
55 | sharedEnvironment: boolean
56 | id: string
57 | prefixLineCount: number
58 | }) {
59 | const { codeVersion, consoleProxy, sharedEnvironment, id } = options
60 |
61 | consoleProxy._rnwp_log = consoleLogRNWP.bind(
62 | consoleProxy,
63 | sendMessage.bind(null, sharedEnvironment),
64 | id,
65 | codeVersion
66 | )
67 |
68 | consoleProxy.log = consoleLog.bind(
69 | consoleProxy,
70 | sendMessage.bind(null, sharedEnvironment),
71 | id,
72 | codeVersion,
73 | 'visible'
74 | )
75 |
76 | consoleProxy.clear = consoleClear.bind(
77 | consoleProxy,
78 | sendMessage.bind(null, sharedEnvironment),
79 | id,
80 | codeVersion
81 | )
82 | }
83 |
84 | /**
85 | * Every time we run the application, we re-bind all the logging and error message
86 | * handlers with a new `codeVersion`. This ensures that logs aren't stale. We also
87 | * include the iframe's id to handle the case of multiple preview iframes
88 | */
89 | export function initializeCommunication({
90 | id,
91 | sharedEnvironment,
92 | prefixLineCount,
93 | consoleProxy,
94 | onRunApplication,
95 | }: {
96 | id: string
97 | sharedEnvironment: boolean
98 | prefixLineCount: number
99 | consoleProxy: ConsoleProxy
100 | onRunApplication: (context: EvaluationContext) => void
101 | }) {
102 | window.onmessage = (e: MessageEvent) => {
103 | if (!e.data || e.data.source !== 'rnwp') return
104 |
105 | const { entry, fileMap, codeVersion } = e.data as {
106 | entry: string
107 | fileMap: Record
108 | codeVersion: number
109 | }
110 |
111 | bindConsoleLogMethods({
112 | codeVersion,
113 | consoleProxy,
114 | sharedEnvironment,
115 | id,
116 | prefixLineCount,
117 | })
118 |
119 | window.onerror = createWindowErrorHandler({
120 | prefixLineCount,
121 | id,
122 | codeVersion,
123 | })
124 |
125 | onRunApplication({ entry, fileMap, codeVersion, requireCache: {} })
126 | }
127 | }
128 |
129 | /**
130 | * Enhance console logs to allow React elements to be rendered in the parent frame
131 | */
132 | export function enhanceConsoleLogs(message: Message) {
133 | if (message.type === 'console' && message.payload.command === 'log') {
134 | message.payload.data = message.payload.data.map((log) => {
135 | if (isValidElement(log as any)) {
136 | return {
137 | __is_react_element: true,
138 | element: log,
139 | ReactDOM,
140 | }
141 | } else {
142 | return log
143 | }
144 | })
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/utils/queryString.ts:
--------------------------------------------------------------------------------
1 | export type QueryParameters = Record
2 |
3 | export function decode(string: string) {
4 | let params: QueryParameters = {}
5 |
6 | if (string.length === 0) return params
7 |
8 | let vars = string.split('&')
9 |
10 | for (let i = 0; i < vars.length; i++) {
11 | let [key, value] = vars[i].split('=')
12 |
13 | // Duplicate entry
14 | if (key in params) {
15 | throw new Error(`Duplicate url parameter: ${key}`)
16 | }
17 |
18 | params[key] = decodeURIComponent(value)
19 | }
20 |
21 | return params
22 | }
23 |
24 | export function encode(params: Record) {
25 | const vars = []
26 |
27 | for (let key in params) {
28 | vars.push(`${key}=${encodeURIComponent(params[key])}`)
29 | }
30 |
31 | return vars.join('&')
32 | }
33 |
--------------------------------------------------------------------------------
/src/workers/pythonWorker.ts:
--------------------------------------------------------------------------------
1 | import { document, reset, DOMCanvasElement } from '../utils/MockDOM'
2 |
3 | const context: Worker & {
4 | importScripts: (...urls: string[]) => void // Why isn't this part of the TS lib?
5 |
6 | // Pyodide
7 | pyodide: Pyodide
8 | languagePluginLoader: Promise
9 |
10 | // Globals passed to python
11 | __source__: string
12 | __variables__: string[]
13 | __log__: (line: number, col: number, ...args: unknown[]) => void
14 | } = self as any
15 |
16 | interface Pyodide {
17 | runPython: (code: string) => unknown
18 | runPythonAsync: (
19 | code: string,
20 | messageCallback: (...args: any[]) => void,
21 | errorCallback: (...args: any[]) => void
22 | ) => Promise
23 | eval_code: (code: string, namespace: Record) => unknown
24 | loadPackage: (name: string | string[]) => Promise
25 | repr: (obj: unknown) => string
26 | loadedPackages: Record
27 | globals: Record
28 | _module: {
29 | packages: {
30 | import_name_to_package_name: Record
31 | }
32 | }
33 | }
34 |
35 | Object.assign(context, {
36 | document,
37 | window: self,
38 | })
39 |
40 | context.importScripts('https://pyodide-cdn2.iodide.io/v0.15.0/full/pyodide.js')
41 |
42 | export type PythonMessage =
43 | | {
44 | type: 'init'
45 | }
46 | | {
47 | type: 'run'
48 | code: string
49 | requestId: number
50 | }
51 |
52 | export type PythonResponse = {}
53 |
54 | const DeleteGlobals = `
55 | import js
56 |
57 | js_vars__ = js.self.__variables__
58 | global_keys__ = list(globals().keys())
59 |
60 | for key__ in global_keys__:
61 | if (key__ != "js") and (key__ not in js_vars__) and (not key__.endswith('__')):
62 | del globals()[key__]
63 | `
64 |
65 | const ConsoleLogTransformer = `
66 | import ast
67 | import js
68 |
69 | __log__ = js.self.__log__
70 |
71 | class LogEnhancer(ast.NodeTransformer):
72 | def visit_Call(self, node: ast.Call):
73 | if type(node.func).__name__ == "Name" and node.func.id == "print":
74 | node.func.id = "__log__"
75 |
76 | line = ast.Num(node.lineno)
77 | line.lineno = node.lineno
78 | line.col_offset = node.col_offset
79 | node.args.insert(0, line)
80 |
81 | col = ast.Num(node.col_offset)
82 | col.lineno = node.lineno
83 | col.col_offset = node.col_offset
84 | node.args.insert(1, col)
85 |
86 | return node
87 |
88 | tree = ast.parse(js.self.__source__)
89 | optimizer = LogEnhancer()
90 | tree = optimizer.visit(tree)
91 |
92 | code = compile(tree, "", "exec")
93 | exec(code)
94 | `
95 |
96 | /**
97 | * Handle pyplot figures as a special case, drawing the figure as an image
98 | */
99 | function isPlot(obj: any) {
100 | try {
101 | if (obj.__name__ === 'matplotlib.pyplot') {
102 | return true
103 | }
104 | } catch {
105 | return false
106 | }
107 | }
108 |
109 | export type TransferableImage = {
110 | marker: '__rnwp_transferable_image__'
111 | buffer: Uint8ClampedArray
112 | width: number
113 | height: number
114 | }
115 |
116 | function extractImageData(plt: any): TransferableImage {
117 | plt.gcf().canvas.show()
118 | const canvas: DOMCanvasElement = plt.gcf().canvas.get_element('canvas')
119 | const image: ImageData = canvas.getContext().getImageData()
120 | const arrayBuffer = image.data
121 |
122 | return {
123 | marker: '__rnwp_transferable_image__',
124 | buffer: arrayBuffer,
125 | width: (canvas.attributes.width as number) || 0,
126 | height: (canvas.attributes.height as number) || 0,
127 | }
128 | }
129 |
130 | function handleMessage(message: PythonMessage): Promise {
131 | switch (message.type) {
132 | case 'init':
133 | return context.languagePluginLoader.then(() => {
134 | return {}
135 | })
136 | case 'run':
137 | return context.languagePluginLoader.then(() => {
138 | const { code, requestId } = message
139 | const pyodide = context.pyodide
140 |
141 | if (!context.__variables__) {
142 | context.__variables__ = context.pyodide.runPython(
143 | `list(globals().keys())`
144 | ) as string[]
145 | } else {
146 | reset()
147 | pyodide.runPython(DeleteGlobals)
148 | }
149 |
150 | // We expose the current source code and special logging function as globals, since
151 | // that seems to be the easiest way to pass variables into the code after our AST transformation
152 | context.__source__ = code
153 | context.__log__ = (line: number, col: number, ...args: unknown[]) => {
154 | const logs = args.map((arg, index) =>
155 | isPlot(arg)
156 | ? extractImageData(arg)
157 | : typeof arg === 'function'
158 | ? pyodide.globals.repr(arg)
159 | : arg
160 | )
161 |
162 | context.postMessage({
163 | type: 'log',
164 | payload: { line, col, logs, requestId },
165 | })
166 | }
167 | return findAndLoadImports(pyodide).then(() => {
168 | try {
169 | pyodide.runPython(ConsoleLogTransformer)
170 | } catch (error) {
171 | const message = formatPythonError(error)
172 | if (message) {
173 | context.postMessage({
174 | type: 'error',
175 | payload: {
176 | requestId,
177 | message,
178 | },
179 | })
180 | }
181 | }
182 |
183 | return {}
184 | })
185 | })
186 | }
187 | }
188 |
189 | const FindImports = `
190 | import ast
191 | import js
192 | import sys
193 |
194 | def find_imports(code):
195 | mod = ast.parse(code)
196 | imports = set()
197 | for node in ast.walk(mod):
198 | if isinstance(node, ast.Import):
199 | for name in node.names:
200 | name = name.name
201 | imports.add(name.split(".")[0])
202 | elif isinstance(node, ast.ImportFrom):
203 | name = node.module
204 | imports.add(name.split(".")[0])
205 | return list(imports.difference(sys.builtin_module_names))
206 |
207 | find_imports(js.globalThis.__source__)
208 | `
209 |
210 | /**
211 | * Scan the global __source__ for imports and load them.
212 | *
213 | * We can't use the built-in functionality for doing this, since we apply an AST transformation
214 | * before running the code, and we can't generate source code from the transformed AST. The API
215 | * for scanning imports isn't public
216 | */
217 | function findAndLoadImports(pyodide: Pyodide): Promise {
218 | const findImports = () => {
219 | try {
220 | // Any errors thrown here will also be thrown when running the code.
221 | // We'll ignore the error here and handle it later instead.
222 | return pyodide.runPython(FindImports) as string[]
223 | } catch (e) {
224 | return []
225 | }
226 | }
227 |
228 | const newImports = findImports()
229 | .map((name) => pyodide._module.packages.import_name_to_package_name[name])
230 | .filter((name) => name && !(name in pyodide.loadedPackages))
231 |
232 | return pyodide.loadPackage(newImports)
233 | }
234 |
235 | const errorRe = /File "", line (\d+)/
236 |
237 | function formatPythonError(error: Error): string | undefined {
238 | const message = error.message
239 | const match = message.match(errorRe)
240 |
241 | if (!match) return
242 |
243 | const lineNumber = match[1]
244 | const lines = message.split('\n')
245 | const summary = lines[lines.length - 2]
246 |
247 | return `${summary} (${lineNumber})\n\n${lines.slice(0, -2).join('\n')}`
248 | }
249 |
250 | interface PythonMessageEvent extends MessageEvent {
251 | data: { id: string; payload: PythonMessage }
252 | }
253 |
254 | onmessage = (e: PythonMessageEvent) => {
255 | const { id, payload } = e.data
256 |
257 | handleMessage(payload).then((message) => {
258 | context.postMessage({
259 | id,
260 | payload: message,
261 | })
262 | })
263 | }
264 |
--------------------------------------------------------------------------------
/src/workers/typescript/LanguageServiceHost.ts:
--------------------------------------------------------------------------------
1 | import * as ts from 'typescript'
2 | import { exists, writeFile, getFile, getPaths } from './fileSystem'
3 | import { fs } from './system'
4 |
5 | type TSLanguageServiceHost = Parameters[0]
6 |
7 | export default class LanguageServiceHost implements TSLanguageServiceHost {
8 | constructor(public compilerOptions: ts.CompilerOptions) {}
9 |
10 | versions: Record = {}
11 |
12 | fileExists(fileName: string) {
13 | return exists(fs, fileName)
14 | }
15 |
16 | addFile(fileName: string, text: string, version?: string) {
17 | version = version
18 | ? version
19 | : getFile(fs, fileName)
20 | ? String(Number(this.versions[fileName]) + 1)
21 | : '1'
22 |
23 | writeFile(fs, fileName, text)
24 |
25 | this.versions[fileName] = version
26 | }
27 |
28 | // Implementation of ts.LanguageServiceHost
29 |
30 | getCompilationSettings(): ts.CompilerOptions {
31 | return this.compilerOptions
32 | }
33 |
34 | getScriptFileNames() {
35 | return getPaths(fs).filter((name) => exists(fs, name))
36 | }
37 |
38 | getScriptVersion(fileName: string) {
39 | return this.versions[fileName]
40 | }
41 |
42 | getScriptSnapshot(fileName: string) {
43 | return ts.ScriptSnapshot.fromString(getFile(fs, fileName) || '')
44 | }
45 |
46 | getCurrentDirectory() {
47 | return '/'
48 | }
49 |
50 | getDefaultLibFileName(options: ts.CompilerOptions) {
51 | return ts.getDefaultLibFileName(options)
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/workers/typescript/fileSystem.ts:
--------------------------------------------------------------------------------
1 | import { join, sep } from '../../utils/path'
2 |
3 | export type DirectoryEntry = string | Directory
4 |
5 | export interface Directory {
6 | [key: string]: DirectoryEntry
7 | }
8 |
9 | export function contains(
10 | directory: Directory,
11 | name: T
12 | ): directory is Directory & { [key in T]: DirectoryEntry } {
13 | return name in directory
14 | }
15 |
16 | export function hasFile(
17 | directory: Directory,
18 | name: T
19 | ): directory is Directory & { [key in T]: string } {
20 | return contains(directory, name) && typeof directory[name] === 'string'
21 | }
22 |
23 | export function hasDirectory(
24 | directory: Directory,
25 | name: T
26 | ): directory is Directory & { [key in T]: Directory } {
27 | return contains(directory, name) && typeof directory[name] === 'object'
28 | }
29 |
30 | export function locate(
31 | directory: Directory,
32 | filepath: string
33 | ):
34 | | {
35 | parent: Directory
36 | name: string
37 | }
38 | | undefined {
39 | const components = filepath.split(sep).filter((x) => x !== '')
40 |
41 | if (components.length === 0) return
42 |
43 | let current: Directory = directory
44 |
45 | for (let i = 0; i < components.length - 1; i++) {
46 | const component = components[i]
47 |
48 | if (hasDirectory(current, component)) {
49 | current = current[component]
50 | continue
51 | } else {
52 | const next: Directory = {}
53 | current[component] = next
54 | current = next
55 | }
56 | }
57 |
58 | return {
59 | parent: current,
60 | name: components[components.length - 1],
61 | }
62 | }
63 |
64 | export function exists(directory: Directory, filepath: string) {
65 | return !!locate(directory, filepath)
66 | }
67 |
68 | export function getDirectory(
69 | directory: Directory,
70 | filepath: string
71 | ): Directory | undefined {
72 | const target = locate(directory, filepath)
73 |
74 | if (!target) return
75 |
76 | if (!hasDirectory(target.parent, target.name)) return
77 |
78 | return target.parent[target.name]
79 | }
80 |
81 | export function getFile(
82 | directory: Directory,
83 | filepath: string
84 | ): string | undefined {
85 | const target = locate(directory, filepath)
86 |
87 | if (!target) return
88 |
89 | if (!hasFile(target.parent, target.name)) return
90 |
91 | return target.parent[target.name]
92 | }
93 |
94 | export function makeDirectory(directory: Directory, filepath: string) {
95 | const target = locate(directory, filepath)
96 |
97 | if (target) {
98 | target.parent[target.name] = {}
99 | } else {
100 | throw new Error(`Failed to locate ${filepath}`)
101 | }
102 | }
103 |
104 | export function readDirectory(
105 | directory: Directory,
106 | filepath: string
107 | ): string[] {
108 | const target = getDirectory(directory, filepath)
109 |
110 | if (!target) return []
111 |
112 | return Object.keys(target)
113 | }
114 |
115 | export function writeFile(
116 | directory: Directory,
117 | filepath: string,
118 | data: string
119 | ) {
120 | const target = locate(directory, filepath)
121 |
122 | if (target) {
123 | target.parent[target.name] = data
124 | } else {
125 | throw new Error(`Failed to locate ${filepath}`)
126 | }
127 | }
128 |
129 | export function getPaths(directory: Directory): string[] {
130 | function inner(path: string, directory: Directory): string[] {
131 | const names = Object.keys(directory)
132 |
133 | return names
134 | .map((name) => join(path, name))
135 | .concat(
136 | ...names.map((name) => {
137 | if (hasDirectory(directory, name)) {
138 | return inner(join(path, name), directory[name])
139 | } else {
140 | return []
141 | }
142 | })
143 | )
144 | }
145 |
146 | return inner('', directory)
147 | }
148 |
149 | export function create(): Directory {
150 | return {}
151 | }
152 |
--------------------------------------------------------------------------------
/src/workers/typescript/system.ts:
--------------------------------------------------------------------------------
1 | import * as ts from 'typescript'
2 | import {
3 | create,
4 | Directory,
5 | getFile,
6 | getDirectory,
7 | makeDirectory,
8 | hasDirectory,
9 | hasFile,
10 | locate,
11 | contains,
12 | } from './fileSystem'
13 |
14 | export const fs: Directory = create()
15 |
16 | export const system: ts.System = {
17 | newLine: '\n',
18 | args: [],
19 | useCaseSensitiveFileNames: true,
20 | getCurrentDirectory: () => '/',
21 | getExecutingFilePath: () => '/',
22 | readDirectory: (filepath) => {
23 | // console.info(`readDirectory`, filepath)
24 | const directory = getDirectory(fs, filepath)
25 | if (!directory) return []
26 | return Object.keys(directory)
27 | },
28 | readFile: (filepath) => {
29 | // console.info(`readFile`, filepath)
30 | return getFile(fs, filepath)
31 | },
32 | write: (s: string) => {
33 | // console.info(`write`, s)
34 | console.log(`write: ${s}`)
35 | },
36 | writeFile: (filepath, data) => {
37 | // console.info(`writeFile`, filepath)
38 | const target = locate(fs, filepath)
39 | if (!target) return
40 | return (target.parent[target.name] = data)
41 | },
42 | resolvePath: (filepath) => {
43 | // console.info(`resolvePath`, filepath)
44 | return filepath
45 | },
46 | getDirectories: (filepath) => {
47 | // console.info(`getDirectories`, filepath)
48 | const directory = getDirectory(fs, filepath)
49 | if (!directory) return []
50 | return Object.keys(directory).filter((name) => contains(directory, name))
51 | },
52 | createDirectory: (filepath) => {
53 | // console.info(`createDirectory`, filepath)
54 | makeDirectory(fs, filepath)
55 | },
56 | directoryExists: (filepath) => {
57 | // console.info(`directoryExists`, filepath)
58 | const target = locate(fs, filepath)
59 | if (!target) return false
60 | return hasDirectory(target.parent, target.name)
61 | },
62 | fileExists: (filepath) => {
63 | // console.info(`fileExists`, filepath)
64 | const target = locate(fs, filepath)
65 | if (!target) return false
66 | return hasFile(target.parent, target.name)
67 | },
68 | exit: () => {
69 | // console.info(`exit`)
70 | throw new Error('EXIT')
71 | },
72 | }
73 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "module": "ES2020",
5 | "rootDir": ".",
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "types": ["node"],
9 | "typeRoots": ["node_modules/@types", "src/types"],
10 | "lib": ["DOM", "ES2017"],
11 | "sourceMap": true,
12 | "allowJs": true,
13 | "jsx": "react",
14 | "moduleResolution": "node"
15 | },
16 | "exclude": ["node_modules", "public", "webpack", "dist"]
17 | }
18 |
--------------------------------------------------------------------------------
/webpack/empty.js:
--------------------------------------------------------------------------------
1 | module.exports = {}
2 |
--------------------------------------------------------------------------------
/webpack/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | <%= htmlWebpackPlugin.options.title %>
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/webpack/webpack-embed.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const webpack = require('webpack')
3 |
4 | const { version } = require('../package.json')
5 |
6 | const paths = {
7 | root: path.dirname(__dirname),
8 | get dist() {
9 | return path.join(this.root, 'dist')
10 | },
11 | get playground() {
12 | return path.join(this.root, 'src', 'components', 'embed', 'Playground.tsx')
13 | },
14 | }
15 |
16 | module.exports = {
17 | mode: 'production',
18 | entry: paths.playground,
19 | output: {
20 | path: path.dist,
21 | filename: 'javascript-playgrounds.js',
22 | library: 'javascript-playgrounds',
23 | libraryTarget: 'umd',
24 | globalObject: 'this',
25 | },
26 | module: {
27 | rules: [
28 | {
29 | test: /\.tsx?$/,
30 | use: [
31 | {
32 | loader: 'ts-loader',
33 | options: {
34 | compilerOptions: {
35 | declaration: true,
36 | declarationDir: paths.dist,
37 | },
38 | },
39 | },
40 | ],
41 | exclude: /node_modules/,
42 | },
43 | ],
44 | },
45 | externals: {
46 | react: {
47 | root: 'React',
48 | commonjs2: 'react',
49 | commonjs: 'react',
50 | amd: 'react',
51 | },
52 | 'react-dom': {
53 | root: 'ReactDOM',
54 | commonjs2: 'react-dom',
55 | commonjs: 'react-dom',
56 | amd: 'react-dom',
57 | },
58 | },
59 | plugins: [
60 | new webpack.DefinePlugin({
61 | 'process.env.NODE_ENV': JSON.stringify('production'),
62 | VERSION: JSON.stringify(version),
63 | }),
64 | ],
65 | }
66 |
--------------------------------------------------------------------------------
/webpack/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack')
2 | const merge = require('webpack-merge')
3 | const path = require('path')
4 | const HtmlWebpackPlugin = require('html-webpack-plugin')
5 |
6 | const paths = {
7 | root: path.join(__dirname, '..'),
8 | get src() {
9 | return path.join(this.root, 'src')
10 | },
11 | get index() {
12 | return path.join(this.src, 'index.tsx')
13 | },
14 | get player() {
15 | return path.join(this.src, 'player.tsx')
16 | },
17 | get public() {
18 | return path.join(this.root, 'public')
19 | },
20 | get htmlTemplate() {
21 | return path.join(this.root, 'webpack/index.ejs')
22 | },
23 | }
24 |
25 | const common = merge({
26 | devServer: {
27 | contentBase: paths.public,
28 | },
29 | entry: {
30 | index: paths.index,
31 | player: paths.player,
32 | },
33 | module: {
34 | rules: [
35 | {
36 | test: /\.tsx?$/,
37 | use: ['ts-loader'],
38 | exclude: /node_modules/,
39 | },
40 | {
41 | test: /\.jsx?$/,
42 | exclude: /node_modules/,
43 | use: [
44 | {
45 | loader: 'babel-loader',
46 | options: {
47 | cacheDirectory: true,
48 | },
49 | },
50 | ],
51 | },
52 | {
53 | test: /\.css$/,
54 | use: ['style-loader', 'css-loader'],
55 | },
56 | {
57 | test: /-worker\.ts/,
58 | use: [
59 | {
60 | loader: 'worker-loader',
61 | options: { filename: '[name]-bundle.js', esModule: true },
62 | },
63 | 'ts-loader',
64 | ],
65 | },
66 | {
67 | test: /-worker\.js/,
68 | loader: 'worker-loader',
69 | options: { filename: '[name]-bundle.js', esModule: false },
70 | },
71 | {
72 | test: /\.svg$/i,
73 | loader: 'file-loader',
74 | },
75 | ],
76 | },
77 | node: {
78 | // From babel-standalone:
79 | // Mock Node.js modules that Babel require()s but that we don't
80 | // particularly care about.
81 | fs: 'empty',
82 | module: 'empty',
83 | net: 'empty',
84 | },
85 | output: {
86 | path: paths.public,
87 | filename: '[name]-bundle.js',
88 | globalObject: 'this',
89 | },
90 | resolve: {
91 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
92 | alias: {
93 | '@babel/plugin-transform-unicode-regex': path.join(__dirname, 'empty.js'),
94 | },
95 | },
96 | plugins: [
97 | new HtmlWebpackPlugin({
98 | title: 'JavaScript Playgrounds',
99 | filename: 'index.html',
100 | template: paths.htmlTemplate,
101 | minify: false,
102 | chunks: ['index'],
103 | }),
104 | new HtmlWebpackPlugin({
105 | title: 'Player',
106 | filename: 'player.html',
107 | template: paths.htmlTemplate,
108 | minify: false,
109 | chunks: ['player'],
110 | }),
111 | ],
112 | })
113 |
114 | module.exports = (mode = 'development') => {
115 | const defines = new webpack.DefinePlugin({
116 | 'process.env.NODE_ENV': JSON.stringify(mode),
117 | })
118 |
119 | if (mode === 'production') {
120 | return merge(common, {
121 | mode,
122 | plugins: [defines],
123 | optimization: {
124 | splitChunks: {
125 | name: false,
126 | chunks: 'all',
127 | },
128 | },
129 | })
130 | } else {
131 | return merge(common, {
132 | mode,
133 | devtool: 'source-map',
134 | plugins: [defines],
135 | })
136 | }
137 | }
138 |
--------------------------------------------------------------------------------