├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── .prettierrc ├── .storybook ├── index.globalcss ├── main.js └── preview.js ├── LICENSE ├── README.md ├── example ├── .env ├── .eslintcache ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ └── setupTests.ts ├── tsconfig.json └── yarn.lock ├── package.json ├── postcss.config.js ├── src ├── components │ ├── EditableProxy.tsx │ ├── Editor.tsx │ ├── EnvironmentPreview.tsx │ ├── PropertiesPanel.tsx │ ├── ProxyManager.tsx │ ├── ReferenceWindow.tsx │ ├── SceneOutlinePanel.tsx │ ├── TransformControls.tsx │ ├── TransformControlsModeSelect.tsx │ ├── TransformControlsSpaceSelect.tsx │ ├── UI.tsx │ ├── ViewportSettings.tsx │ ├── ViewportShadingSelect.tsx │ ├── ViewportShadingSettings.tsx │ ├── editable.tsx │ └── elements │ │ ├── Button.tsx │ │ ├── Checkbox.tsx │ │ ├── Code.tsx │ │ ├── CompactModeSelect.tsx │ │ ├── FormControl.tsx │ │ ├── FormLabel.tsx │ │ ├── Heading.tsx │ │ ├── IconButton.tsx │ │ ├── IdProvider.ts │ │ ├── Input.tsx │ │ ├── Legend.tsx │ │ ├── Modal.tsx │ │ ├── Popover.tsx │ │ ├── PortalManager.tsx │ │ ├── SettingsButton.tsx │ │ ├── Tooltip.tsx │ │ ├── VisuallyHidden.ts │ │ └── index.ts ├── index.tsx ├── store.ts ├── styles.css └── types │ └── index.d.ts ├── stories ├── Button.stories.tsx ├── Code.stories.tsx ├── CompactModeSelect.stories.tsx ├── Editor.stories.tsx ├── FormLabel.stories.tsx ├── Heading.stories.tsx ├── IconButton.stories.tsx ├── Input.stories.tsx ├── Legend.stories.tsx ├── Modal.stories.tsx ├── Popover.stories.tsx ├── SetingsButton.stories.tsx ├── Setup.tsx ├── Suitcase.tsx ├── Tooltip.stories.tsx ├── editableState.json └── public │ ├── equi.hdr │ ├── suitcase.gltf │ └── suzanne.glb ├── tailwind.config.js ├── test └── index.test.tsx ├── tsconfig.json ├── tsdx.config.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'react-app', 4 | 'prettier/@typescript-eslint', 5 | ], 6 | settings: { 7 | react: { 8 | version: 'detect', 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: AndrewPrifer 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['10.x', '12.x', '14.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | 7 | .idea 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/index.globalcss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | canvas { 7 | outline: none 8 | } 9 | } 10 | 11 | 12 | 13 | html, 14 | body, 15 | #root { 16 | width: 100%; 17 | height: 100%; 18 | margin: 0; 19 | padding: 0; 20 | -webkit-touch-callout: none; 21 | -webkit-user-select: none; 22 | -khtml-user-select: none; 23 | -moz-user-select: none; 24 | -ms-user-select: none; 25 | user-select: none; 26 | overflow: hidden; 27 | background-color: white; 28 | } 29 | 30 | #root { 31 | overflow: auto; 32 | } 33 | 34 | .sb-show-main { 35 | padding: 0 !important; 36 | } 37 | 38 | .html-story-block { 39 | color: white; 40 | width: 120px; 41 | position: relative; 42 | margin-left: 100px; 43 | } 44 | 45 | .html-story-block:before { 46 | content: ''; 47 | display: block; 48 | position: absolute; 49 | 50 | top: 50%; 51 | left: -60px; 52 | 53 | width: 60px; 54 | height: 1px; 55 | background-color: white; 56 | } 57 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | function regexEqual(x, y) { 4 | return ( 5 | x instanceof RegExp && 6 | y instanceof RegExp && 7 | x.source === y.source && 8 | x.global === y.global && 9 | x.ignoreCase === y.ignoreCase && 10 | x.multiline === y.multiline 11 | ); 12 | } 13 | 14 | module.exports = { 15 | stories: ['../stories/**/*.stories.@(ts|tsx|js|jsx)'], 16 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 17 | // https://storybook.js.org/docs/react/configure/typescript#mainjs-configuration 18 | typescript: { 19 | check: true, // type-check stories during Storybook build 20 | }, 21 | webpackFinal: async (config, { configType }) => { 22 | let oldCssRule; 23 | config.module.rules.forEach((rule) => { 24 | if (regexEqual(rule.test, /\.css$/)) { 25 | oldCssRule = { ...rule }; 26 | oldCssRule.use = [...rule.use]; 27 | rule.use.shift(); 28 | rule.use.unshift('to-string-loader'); 29 | } 30 | }); 31 | 32 | oldCssRule.test = /\.globalcss$/; 33 | config.module.rules.push(oldCssRule); 34 | 35 | return config; 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import './index.globalcss'; 2 | 3 | // https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters 4 | export const parameters = { 5 | // https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args 6 | actions: { argTypesRegex: '^on.*' }, 7 | }; 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Andrew Prifer 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Three Editable 2 | 3 | > ⚠️ React Three Editable is now part of [Theatre.js](https://github.com/theatre-js/theatre) and can be found on NPM as `@theatre/r3f`. The new project is functionally almost the same as this one, just with more features, regular maintenance and... animation tools! 🎉 Chances are your current r3e code will work without any modifications. 4 | > 5 | > For those that haven't heard the news, I'm now also working on Theatre.js, and `@theatre/r3f` will be receiving regular improvements and maintenance as an official Theatre.js extension. 6 | > 7 | > With that, however, this package (`react-three-editable`) is deprecated in favor of `@theatre/r3f`. I encourage you all to check it out at [Theatre.js](https://github.com/theatre-js/theatre) and file issues there! 8 | 9 | React Three Editable is a library for React and react-three-fiber that lets you edit your scene in a visual editor while requiring minimal modifications to your r3f code. To get a quick idea of what it's all about, please take a look at this [codesandbox](https://codesandbox.io/s/ide-cream-demo-hcgcd). 10 | 11 | **Here be dragons! 🐉** React Three Editable is relatively stable, however being pre-1.0.0 software, the API and the internal logic can drastically change at any time, without warning. 12 | 13 | ## Quick start 14 | 15 | ``` 16 | yarn add react-three-editable 17 | ``` 18 | 19 | ```tsx 20 | import React from 'react'; 21 | import { Canvas } from 'react-three-fiber'; 22 | import { editable as e, configure } from 'react-three-editable'; 23 | 24 | // Import your previously exported state 25 | import editableState from './editableState.json'; 26 | 27 | const bind = configure({ 28 | // Enables persistence in development so your edits aren't discarded when you close the browser window 29 | enablePersistence: true, 30 | // Useful if you use r3e in multiple projects 31 | localStorageNamespace: 'Example', 32 | }); 33 | 34 | export default function App() { 35 | return ( 36 | 37 | 38 | {/* Mark objects as editable. */} 39 | {/* Properties in the code are used as initial values and reset points in the editor. */} 40 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | ``` 55 | 56 | ([Codesandbox](https://codesandbox.io/s/minimal-r3e-demo-o1brl)) 57 | 58 | ## Why 59 | 60 | When creating a 3D scene for react-three-fiber, you can choose two routes: you can either code it in r3f, which gives you reactivity, and the flexibility that comes with it, or you can use a 3D software like Blender and export it, but then if you want to dynamically modify that scene at runtime, you'll have to fall back to imperative code. 61 | 62 | The best middle ground so far has been *gltfjsx*, which generates JSX from your exported scene, however it still involves a lot of manual work if you want to split your scene into components, and any modifications you make will have to be reapplied if you make changes to the scene. 63 | 64 | React Three Editable aims to fill this gap by allowing you to set up your scene in JSX, giving you reactivity, while allowing you to tweak the properties of these objects in a visual editor, including their transforms, which you can then bake into a json file to be used by the runtime in production. An explicit goal of the project is to mirror regular react-three-fiber code as much as possible. This lets you add it to an existing project with ease, take it out when you don't need it, and generally use it as little or as much as you want, without feeling locked in. 65 | 66 | ## API 67 | 68 | ### `editable` 69 | 70 | Use it to make objects editable. The properties on `editable` mirror the intrinsic elements of react-three-fiber, however there's no full parity yet. E.g. if you want to create an editable ``, you do it by using `` instead. These elements have the same interface as the normal ones, with the addition of the below props. Any editable property you set in the code (like `position`) will be used as an initial value/reset point in the editor. 71 | 72 | `editable` is also a function, which allows you to make your custom components editable. Your component does have to be compatible with the interface of the editable object type it is meant to represent. You need to pass it the component you want to wrap, and the object type it represents (see object types). 73 | 74 | ```ts 75 | import { editable } from 'react-three-editable'; 76 | import { PerspectiveCamera } from '@react-three/drei'; 77 | 78 | const EditableCamera = editable(PerspectiveCamera, 'perspectiveCamera'); 79 | ``` 80 | 81 | #### Props 82 | 83 | `uniqueName: string`: a unique name used to identify the object in the editor. 84 | 85 | ### `configure(options)` 86 | 87 | Lets you configure the editor. 88 | 89 | #### Parameters 90 | 91 | `options.localStorageNamespace: string = ''`: allows you to namespace the key used for automatically persisting the editor state in development. Useful if you're working on multiple projects at the same time and you don't want one project overwriting the other. 92 | 93 | `options.enablePersistence: boolean = true`: sets whether to enable persistence or not. 94 | 95 | #### Returns 96 | 97 | `bind`: a function that you can use to connect canvases to React Three Editable, see below. 98 | 99 | ### `bind(options)` 100 | 101 | Bind is a curried function that you call with `options` and returns another function that has to be called with `gl` and `scene`. 102 | 103 | Use it to bind a `Canvas` to React Three Editable: ``. If you use `onCreated` for other things as well, you have to manually call the function returned by `bind()`: 104 | 105 | ```tsx 106 | { 107 | bind(options)({ gl, scene }); 108 | }}> 109 | // ... 110 | 111 | ``` 112 | 113 | ❓ "The above snippet looks wrong...": `bind` is a curried function, so that you don't have to pass `gl` and `scene` manually in the average case when your `onCreated` is empty. The downside is that it looks like this when you have to manually call it with `gl` and `scene`. 114 | 115 | ⚠️ For now, you can only connect a single canvas, however multi-canvas support is planned. 116 | 117 | #### Parameters 118 | 119 | `options.state?: EditableState`: a previously exported state. 120 | 121 | `options.allowImplicitInstancing: boolean = false`: allows implicit instancing of editable objects through reusing `uniqueName`s. These objects will share all editable properties. It is discouraged since you'll miss out on warnings if you accidentally reuse a `uniqueName`, and will be superseded by prefabs in the future. 122 | 123 | 124 | ## Object types 125 | 126 | React Three Editable currently supports the following object types: 127 | 128 | - group 129 | - mesh 130 | - spotLight 131 | - directionalLight 132 | - pointLight 133 | - perspectiveCamera 134 | - orthographicCamera 135 | 136 | These are available as properties of `editable`, and you need to pass them as the second parameter when wrapping custom components. 137 | 138 | ## Production/development build 139 | 140 | React Three Editable automatically displays the editor in development and removes it in production, similarly to how React has two different builds. To make use of this, you need to have your build system set up to handle `process.env.NODE_ENV` checks. If you are using CRA or Next.js, this is already done for you. 141 | 142 | In production, the bundle size of r3e is [2.9 kB](https://bundlephobia.com/result?p=react-three-editable). 143 | 144 | ## Contributing 145 | 146 | **Any help is welcome!** 147 | 148 | This project is still very much in the concept phase, so feedback and ideas are just as valuable a contribution as helping out with the code, or supporting development. 149 | 150 | If you have time, please go through the issues and see if there's anything you can help with. 151 | -------------------------------------------------------------------------------- /example/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | DISABLE_NEW_JSX_TRANSFORM=true 3 | -------------------------------------------------------------------------------- /example/.eslintcache: -------------------------------------------------------------------------------- 1 | [{"/Users/andrew/Projects/react-three-editable/example/src/App.tsx":"1"},{"size":1531,"mtime":1609089394049,"results":"2","hashOfConfig":"3"},{"filePath":"4","messages":"5","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1e3o450","/Users/andrew/Projects/react-three-editable/example/src/App.tsx",[]] -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.11.4", 7 | "@testing-library/react": "^11.1.0", 8 | "@testing-library/user-event": "^12.1.10", 9 | "@types/jest": "^26.0.15", 10 | "@types/node": "^12.0.0", 11 | "react-scripts": "4.0.1", 12 | "web-vitals": "^0.2.4" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": [ 22 | "react-app", 23 | "react-app/jest" 24 | ] 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndrewPrifer/react-three-editable/6e4224d8466df80b4118cd70545fa3a39fb1c74f/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndrewPrifer/react-three-editable/6e4224d8466df80b4118cd70545fa3a39fb1c74f/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndrewPrifer/react-three-editable/6e4224d8466df80b4118cd70545fa3a39fb1c74f/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Canvas } from 'react-three-fiber'; 3 | import { editable as e, configure } from '../../'; 4 | import { PerspectiveCamera } from '@react-three/drei'; 5 | 6 | const bind = configure(); 7 | 8 | const ECamera = e(PerspectiveCamera, 'perspectiveCamera'); 9 | 10 | function App() { 11 | return ( 12 | 13 | 14 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | 52 | export default App; 53 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body, 7 | #root { 8 | width: 100%; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | } 13 | 14 | body { 15 | background: white; 16 | } 17 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.8.1", 3 | "license": "MIT", 4 | "description": "️Edit your react-three-fiber scene with a visual editor without giving up control over your code.", 5 | "keywords": [ 6 | "3d", 7 | "editor", 8 | "react", 9 | "react-three-fiber", 10 | "r3f", 11 | "three", 12 | "threejs", 13 | "webgl", 14 | "webgl2" 15 | ], 16 | "repository": "AndrewPrifer/react-three-editable", 17 | "main": "dist/index.js", 18 | "typings": "dist/index.d.ts", 19 | "files": [ 20 | "dist", 21 | "src" 22 | ], 23 | "engines": { 24 | "node": ">=10" 25 | }, 26 | "scripts": { 27 | "start": "tsdx watch", 28 | "build": "NODE_ENV=production tsdx build --format cjs", 29 | "test": "tsdx test --passWithNoTests", 30 | "lint": "tsdx lint", 31 | "prepare": "NODE_ENV=production tsdx build --format cjs", 32 | "size": "size-limit", 33 | "analyze": "size-limit --why", 34 | "storybook": "start-storybook -s ./stories/public -p 6006", 35 | "build-storybook": "build-storybook", 36 | "np": "NODE_ENV=production np --any-branch" 37 | }, 38 | "peerDependencies": { 39 | "react": ">=16", 40 | "react-dom": ">=16", 41 | "react-three-fiber": "*", 42 | "three": "*" 43 | }, 44 | "husky": { 45 | "hooks": { 46 | "pre-commit": "tsdx lint" 47 | } 48 | }, 49 | "prettier": { 50 | "printWidth": 80, 51 | "semi": true, 52 | "singleQuote": true, 53 | "trailingComma": "es5" 54 | }, 55 | "name": "react-three-editable", 56 | "author": "Andrew Prifer", 57 | "size-limit": [ 58 | { 59 | "path": "dist/react-three-editable.cjs.production.min.js", 60 | "limit": "10 KB" 61 | } 62 | ], 63 | "devDependencies": { 64 | "@babel/core": "^7.12.3", 65 | "@size-limit/preset-small-lib": "^4.6.0", 66 | "@storybook/addon-essentials": "^6.1.6", 67 | "@storybook/addon-info": "^5.3.21", 68 | "@storybook/addon-links": "^6.1.6", 69 | "@storybook/addons": "^6.1.6", 70 | "@storybook/react": "^6.1.6", 71 | "@tailwindcss/forms": "^0.2.1", 72 | "@types/react": "^17.0.0", 73 | "@types/react-dom": "^17.0.0", 74 | "@typescript-eslint/eslint-plugin": "^4.8.2", 75 | "@typescript-eslint/parser": "^4.8.2", 76 | "autoprefixer": "^10.0.2", 77 | "babel-loader": "^8.1.0", 78 | "eslint-plugin-prettier": "^3.1.4", 79 | "husky": "^4.3.0", 80 | "postcss": "^8.1.10", 81 | "prettier": "^2.1.2", 82 | "react": "^17.0.1", 83 | "react-dom": "^17.0.1", 84 | "react-is": "^17.0.1", 85 | "react-three-fiber": "^5.3.4", 86 | "rollup-plugin-postcss": "^3.1.8", 87 | "size-limit": "^4.6.0", 88 | "tailwindcss": "^2.0.1", 89 | "three": "^0.121.1", 90 | "to-string-loader": "^1.1.6", 91 | "tsdx": "^0.14.1", 92 | "tslib": "^2.0.3", 93 | "typescript": "4.0.5" 94 | }, 95 | "dependencies": { 96 | "@react-icons/all-files": "^4.1.0", 97 | "@react-three/drei": "^2.2.10", 98 | "@types/file-saver": "^2.0.1", 99 | "fast-deep-equal": "^3.1.3", 100 | "file-saver": "^2.0.2", 101 | "prism-react-renderer": "^1.1.1", 102 | "prop-types": "^15.7.2", 103 | "react-hook-form": "^6.11.0", 104 | "react-merge-refs": "^1.1.0", 105 | "react-shadow": "^18.5.1", 106 | "react-use-measure": "^2.0.3", 107 | "reakit": "^1.3.0", 108 | "zustand": "^3.2.0" 109 | }, 110 | "resolutions": { 111 | "postcss": "8.1.7", 112 | "**/typescript": "4.0.5", 113 | "**/@typescript-eslint/eslint-plugin": "^4.6.1", 114 | "**/@typescript-eslint/parser": "^4.6.1" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/EditableProxy.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BoxHelper, 3 | CameraHelper, 4 | DirectionalLightHelper, 5 | Object3D, 6 | PointLightHelper, 7 | SpotLightHelper, 8 | } from 'three'; 9 | import React, { 10 | ReactElement, 11 | useEffect, 12 | useLayoutEffect, 13 | useRef, 14 | useState, 15 | VFC, 16 | } from 'react'; 17 | import { useHelper, Sphere, Html } from '@react-three/drei'; 18 | import { EditableType, useEditorStore } from '../store'; 19 | import shallow from 'zustand/shallow'; 20 | import { BsFillCollectionFill } from '@react-icons/all-files/bs/BsFillCollectionFill'; 21 | import { GiLightProjector } from '@react-icons/all-files/gi/GiLightProjector'; 22 | import { BiSun } from '@react-icons/all-files/bi/BiSun'; 23 | import { GiCube } from '@react-icons/all-files/gi/GiCube'; 24 | import { GiLightBulb } from '@react-icons/all-files/gi/GiLightBulb'; 25 | import { BsCameraVideoFill } from '@react-icons/all-files/bs/BsCameraVideoFill'; 26 | import { IconType } from '@react-icons/all-files'; 27 | 28 | export interface EditableProxyProps { 29 | editableName: string; 30 | editableType: EditableType; 31 | object: Object3D; 32 | onChange?: () => void; 33 | } 34 | 35 | const EditableProxy: VFC = ({ 36 | editableName, 37 | editableType, 38 | object, 39 | }) => { 40 | const [ 41 | selected, 42 | showOverlayIcons, 43 | setSelected, 44 | setSnapshotProxyObject, 45 | ] = useEditorStore( 46 | (state) => [ 47 | state.selected, 48 | state.showOverlayIcons, 49 | state.setSelected, 50 | state.setSnapshotProxyObject, 51 | ], 52 | shallow 53 | ); 54 | 55 | useEffect(() => { 56 | setSnapshotProxyObject(object, editableName); 57 | 58 | return () => setSnapshotProxyObject(null, editableName); 59 | }, [editableName, object, setSnapshotProxyObject]); 60 | 61 | // set up helper 62 | let Helper: 63 | | typeof SpotLightHelper 64 | | typeof DirectionalLightHelper 65 | | typeof PointLightHelper 66 | | typeof BoxHelper 67 | | typeof CameraHelper; 68 | 69 | switch (editableType) { 70 | case 'spotLight': 71 | Helper = SpotLightHelper; 72 | break; 73 | case 'directionalLight': 74 | Helper = DirectionalLightHelper; 75 | break; 76 | case 'pointLight': 77 | Helper = PointLightHelper; 78 | break; 79 | case 'perspectiveCamera': 80 | case 'orthographicCamera': 81 | Helper = CameraHelper; 82 | break; 83 | case 'group': 84 | case 'mesh': 85 | Helper = BoxHelper; 86 | } 87 | 88 | let helperArgs: [string] | [number, string] | []; 89 | const size = 1; 90 | const color = 'darkblue'; 91 | 92 | switch (editableType) { 93 | case 'directionalLight': 94 | case 'pointLight': 95 | helperArgs = [size, color]; 96 | break; 97 | case 'group': 98 | case 'mesh': 99 | case 'spotLight': 100 | helperArgs = [color]; 101 | break; 102 | case 'perspectiveCamera': 103 | case 'orthographicCamera': 104 | helperArgs = []; 105 | } 106 | 107 | let icon: ReactElement; 108 | switch (editableType) { 109 | case 'group': 110 | icon = ; 111 | break; 112 | case 'mesh': 113 | icon = ; 114 | break; 115 | case 'pointLight': 116 | icon = ; 117 | break; 118 | case 'spotLight': 119 | icon = ; 120 | break; 121 | case 'directionalLight': 122 | icon = ; 123 | break; 124 | case 'perspectiveCamera': 125 | case 'orthographicCamera': 126 | icon = ; 127 | } 128 | 129 | const objectRef = useRef(object); 130 | 131 | useLayoutEffect(() => { 132 | objectRef.current = object; 133 | }, [object]); 134 | 135 | const dimensionless = [ 136 | 'spotLight', 137 | 'pointLight', 138 | 'directionalLight', 139 | 'perspectiveCamera', 140 | 'orthographicCamera', 141 | ]; 142 | 143 | const [hovered, setHovered] = useState(false); 144 | 145 | useHelper( 146 | objectRef, 147 | selected === editableName || dimensionless.includes(editableType) || hovered 148 | ? Helper 149 | : null, 150 | ...helperArgs 151 | ); 152 | 153 | return ( 154 | <> 155 | { 157 | if (e.delta < 2) { 158 | e.stopPropagation(); 159 | setSelected(editableName); 160 | } 161 | }} 162 | onPointerOver={(e) => { 163 | e.stopPropagation(); 164 | setHovered(true); 165 | }} 166 | onPointerOut={(e) => { 167 | e.stopPropagation(); 168 | setHovered(false); 169 | }} 170 | > 171 | 172 | {showOverlayIcons && ( 173 | 177 | {icon} 178 | 179 | )} 180 | {dimensionless.includes(editableType) && ( 181 | { 184 | if (e.delta < 2) { 185 | e.stopPropagation(); 186 | setSelected(editableName); 187 | } 188 | }} 189 | userData={{ helper: true }} 190 | > 191 | 192 | 193 | )} 194 | 195 | 196 | 197 | ); 198 | }; 199 | 200 | export default EditableProxy; 201 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useLayoutEffect, 4 | useRef, 5 | useState, 6 | VFC, 7 | Suspense, 8 | } from 'react'; 9 | import { Canvas, useThree } from 'react-three-fiber'; 10 | import { useEditorStore } from '../store'; 11 | import { OrbitControls, Environment } from '@react-three/drei'; 12 | import shallow from 'zustand/shallow'; 13 | import root from 'react-shadow'; 14 | import styles from '../styles.css'; 15 | import UI from './UI'; 16 | import ProxyManager from './ProxyManager'; 17 | import { 18 | Button, 19 | Heading, 20 | Code, 21 | PortalManager, 22 | Modal, 23 | ModalHeader, 24 | ModalFooter, 25 | ModalBody, 26 | IdProvider, 27 | } from './elements'; 28 | 29 | const EditorScene = () => { 30 | const orbitControlsRef = useRef(); 31 | const { camera } = useThree(); 32 | 33 | const [ 34 | selectedHdr, 35 | useHdrAsBackground, 36 | showGrid, 37 | showAxes, 38 | setOrbitControlsRef, 39 | ] = useEditorStore( 40 | (state) => [ 41 | state.selectedHdr, 42 | state.useHdrAsBackground, 43 | state.showGrid, 44 | state.showAxes, 45 | state.setOrbitControlsRef, 46 | ], 47 | shallow 48 | ); 49 | 50 | useEffect(() => { 51 | setOrbitControlsRef(orbitControlsRef); 52 | }, [camera, setOrbitControlsRef]); 53 | 54 | return ( 55 | <> 56 | 57 | {selectedHdr && ( 58 | 64 | )} 65 | 66 | {showGrid && } 67 | {showAxes && } 68 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | const Editor: VFC = () => { 75 | const [ 76 | sceneSnapshot, 77 | editorOpen, 78 | initialState, 79 | setEditorOpen, 80 | setSelected, 81 | createSnapshot, 82 | isPersistedStateDifferentThanInitial, 83 | applyPersistedState, 84 | ] = useEditorStore( 85 | (state) => [ 86 | state.sceneSnapshot, 87 | state.editorOpen, 88 | state.initialState, 89 | state.setEditorOpen, 90 | state.setSelected, 91 | state.createSnapshot, 92 | state.isPersistedStateDifferentThanInitial, 93 | state.applyPersistedState, 94 | ], 95 | shallow 96 | ); 97 | 98 | const [stateMismatch, setStateMismatch] = useState(false); 99 | 100 | useLayoutEffect(() => { 101 | if (initialState) { 102 | setStateMismatch(isPersistedStateDifferentThanInitial()); 103 | } else { 104 | applyPersistedState(); 105 | } 106 | }, [applyPersistedState, initialState, isPersistedStateDifferentThanInitial]); 107 | 108 | return ( 109 | 110 |
111 | 112 | 113 |
114 |
117 | {sceneSnapshot ? ( 118 | <> 119 |
120 | { 124 | gl.setClearColor('white'); 125 | }} 126 | shadowMap 127 | pixelRatio={window.devicePixelRatio} 128 | onPointerMissed={() => setSelected(null)} 129 | > 130 | 131 | 132 |
133 | 134 | 135 | 136 | ) : ( 137 |
138 |
139 | 140 | No canvas connected 141 | 142 |
143 | Please use configure() and{' '} 144 | bind() to connect a canvas to React Three 145 | Editable. 146 |
147 | 148 | {`import React from 'react'; 149 | import { Canvas } from 'react-three-fiber'; 150 | import { configure, editable as e } from 'react-three-editable'; 151 | 152 | const bind = configure({ 153 | localStorageNamespace: "MyProject" 154 | }); 155 | 156 | const MyComponent = () => ( 157 | 158 | 159 | 160 | 161 | 162 | 163 | );`} 164 | 165 |
166 | For more details, please consult the{' '} 167 | 173 | documentation 174 | 175 | . 176 |
177 | 185 |
186 |
187 | )} 188 |
189 | {editorOpen || ( 190 | 201 | )} 202 |
203 | 204 | Saved state found 205 | 206 | Would you like to use initial state or saved state? 207 | 208 | 209 | 218 | 226 | 227 | 228 | 229 |
230 |
231 |
232 |
233 | ); 234 | }; 235 | 236 | export default Editor; 237 | -------------------------------------------------------------------------------- /src/components/EnvironmentPreview.tsx: -------------------------------------------------------------------------------- 1 | import React, { VFC, Suspense } from 'react'; 2 | import { Canvas } from 'react-three-fiber'; 3 | import { Environment, OrbitControls, TorusKnot } from '@react-three/drei'; 4 | import { Clickable, ClickableProps } from 'reakit'; 5 | import { IoIosClose } from '@react-icons/all-files/io/IoIosClose'; 6 | 7 | export interface EnvironmentPreviewProps extends ClickableProps { 8 | url: string | null; 9 | selected: boolean; 10 | } 11 | 12 | const EnvironmentPreview: VFC = ({ 13 | url, 14 | selected, 15 | ...props 16 | }) => { 17 | return ( 18 | 27 |
28 | {url ? ( 29 | <> 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 | {url ?? 'None'} 47 |
48 |
49 | 50 | ) : ( 51 |
52 | 53 |
54 | )} 55 |
56 |
57 | ); 58 | }; 59 | 60 | export default EnvironmentPreview; 61 | -------------------------------------------------------------------------------- /src/components/PropertiesPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, VFC } from 'react'; 2 | import { useForm, UseFormMethods } from 'react-hook-form'; 3 | import { useEditorStore } from '../store'; 4 | import { Euler, Matrix4, Quaternion, Vector3 } from 'three'; 5 | import shallow from 'zustand/shallow'; 6 | import { 7 | FormControl, 8 | Heading, 9 | Input, 10 | Legend, 11 | VisuallyHidden, 12 | } from './elements'; 13 | import { Button } from 'reakit'; 14 | import { MdRestore } from '@react-icons/all-files/md/MdRestore'; 15 | 16 | interface Vector3InputProps { 17 | register: UseFormMethods['register']; 18 | onBlur?: () => void; 19 | label: string; 20 | name: string; 21 | } 22 | 23 | const Vector3Input: VFC = ({ 24 | register, 25 | onBlur, 26 | label, 27 | name, 28 | }) => { 29 | return ( 30 |
31 | {label} 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
43 |
44 | ); 45 | }; 46 | 47 | type Inputs = { 48 | positionX: string; 49 | positionY: string; 50 | positionZ: string; 51 | rotationX: string; 52 | rotationY: string; 53 | rotationZ: string; 54 | scaleX: string; 55 | scaleY: string; 56 | scaleZ: string; 57 | }; 58 | 59 | const PropertiesPanel: VFC = () => { 60 | const [selected, setEditableTransform] = useEditorStore( 61 | (state) => [state.selected, state.setEditableTransform], 62 | shallow 63 | ); 64 | 65 | const getFormValuesFromEditable = useCallback(() => { 66 | if (!selected) { 67 | return; 68 | } 69 | 70 | const position = new Vector3(); 71 | const rotation = new Quaternion(); 72 | const scale = new Vector3(); 73 | 74 | useEditorStore 75 | .getState() 76 | .editables[selected].properties.transform.decompose( 77 | position, 78 | rotation, 79 | scale 80 | ); 81 | 82 | const rotationEuler = new Euler(); 83 | rotationEuler.setFromQuaternion(rotation); 84 | 85 | return { 86 | positionX: position.x.toFixed(2), 87 | positionY: position.y.toFixed(2), 88 | positionZ: position.z.toFixed(2), 89 | rotationX: rotationEuler.x.toFixed(2), 90 | rotationY: rotationEuler.y.toFixed(2), 91 | rotationZ: rotationEuler.z.toFixed(2), 92 | scaleX: scale.x.toFixed(2), 93 | scaleY: scale.y.toFixed(2), 94 | scaleZ: scale.z.toFixed(2), 95 | }; 96 | }, [selected]); 97 | 98 | const { handleSubmit, register, setValue, reset } = useForm({ 99 | defaultValues: getFormValuesFromEditable(), 100 | }); 101 | 102 | useEffect(() => { 103 | if (!selected) { 104 | return; 105 | } 106 | 107 | const formValues = getFormValuesFromEditable(); 108 | if (formValues) { 109 | Object.entries(formValues).forEach(([key, value]) => { 110 | // avoids rerenders, unlike reset 111 | setValue(key, value); 112 | }); 113 | } 114 | 115 | const unsub = useEditorStore.subscribe( 116 | () => { 117 | const formValues = getFormValuesFromEditable(); 118 | if (formValues) { 119 | Object.entries(formValues).forEach(([key, value]) => { 120 | // avoids rerenders, unlike reset 121 | setValue(key, value); 122 | }); 123 | } 124 | }, 125 | (state) => state.editables[selected] 126 | ); 127 | 128 | return () => unsub(); 129 | }, [getFormValuesFromEditable, selected, setValue]); 130 | 131 | return selected ? ( 132 |
133 | Properties 134 |
{ 136 | const position = new Vector3( 137 | Number(values.positionX), 138 | Number(values.positionY), 139 | Number(values.positionZ) 140 | ); 141 | const rotation = new Quaternion().setFromEuler( 142 | new Euler( 143 | Number(values.rotationX), 144 | Number(values.rotationY), 145 | Number(values.rotationZ) 146 | ) 147 | ); 148 | const scale = new Vector3( 149 | Number(values.scaleX), 150 | Number(values.scaleY), 151 | Number(values.scaleZ) 152 | ); 153 | const transform = new Matrix4().compose(position, rotation, scale); 154 | setEditableTransform(selected, transform); 155 | })} 156 | > 157 |
158 |
159 |
Transforms
160 | 173 |
174 | reset(getFormValuesFromEditable())} 177 | label="Position" 178 | name="position" 179 | /> 180 | reset(getFormValuesFromEditable())} 183 | label="Rotation" 184 | name="rotation" 185 | /> 186 | reset(getFormValuesFromEditable())} 189 | label="Scale" 190 | name="scale" 191 | /> 192 |
193 | {/* so that submitting with enter works */} 194 | 195 | 196 |
197 | ) : null; 198 | }; 199 | 200 | export default PropertiesPanel; 201 | -------------------------------------------------------------------------------- /src/components/ProxyManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useLayoutEffect, 4 | useMemo, 5 | useRef, 6 | useState, 7 | VFC, 8 | } from 'react'; 9 | import { useEditorStore } from '../store'; 10 | import { createPortal } from 'react-three-fiber'; 11 | import EditableProxy from './EditableProxy'; 12 | import { OrbitControls } from '@react-three/drei'; 13 | import TransformControls from './TransformControls'; 14 | import shallow from 'zustand/shallow'; 15 | import { 16 | Material, 17 | Matrix4, 18 | Mesh, 19 | MeshBasicMaterial, 20 | MeshPhongMaterial, 21 | Object3D, 22 | } from 'three'; 23 | 24 | export interface ProxyManagerProps { 25 | orbitControlsRef: React.MutableRefObject; 26 | } 27 | 28 | type EditableProxy = { 29 | portal: ReturnType; 30 | object: Object3D; 31 | }; 32 | 33 | const ProxyManager: VFC = ({ orbitControlsRef }) => { 34 | const isBeingEdited = useRef(false); 35 | const [ 36 | sceneSnapshot, 37 | selected, 38 | transformControlsMode, 39 | transformControlsSpace, 40 | viewportShading, 41 | setEditableTransform, 42 | ] = useEditorStore( 43 | (state) => [ 44 | state.sceneSnapshot, 45 | state.selected, 46 | state.transformControlsMode, 47 | state.transformControlsSpace, 48 | state.viewportShading, 49 | state.setEditableTransform, 50 | ], 51 | shallow 52 | ); 53 | const sceneProxy = useMemo(() => sceneSnapshot?.clone(), [sceneSnapshot]); 54 | const [editableProxies, setEditableProxies] = useState<{ 55 | [name: string]: EditableProxy; 56 | }>({}); 57 | 58 | // set up scene proxies 59 | useLayoutEffect(() => { 60 | if (!sceneProxy) { 61 | return; 62 | } 63 | 64 | const editableProxies: { [name: string]: EditableProxy } = {}; 65 | 66 | sceneProxy.traverse((object) => { 67 | if (object.userData.__editable) { 68 | // there are duplicate uniqueNames in the scene, only display one instance in the editor 69 | if (editableProxies[object.userData.__editableName]) { 70 | object.parent!.remove(object); 71 | } else { 72 | editableProxies[object.userData.__editableName] = { 73 | portal: createPortal( 74 | , 79 | object.parent 80 | ), 81 | object: object, 82 | }; 83 | } 84 | } 85 | }); 86 | 87 | setEditableProxies(editableProxies); 88 | }, [orbitControlsRef, sceneProxy]); 89 | 90 | // subscribe to external changes 91 | useEffect(() => { 92 | if (!selected) { 93 | return; 94 | } 95 | 96 | const unsub = useEditorStore.subscribe( 97 | (transform) => { 98 | if (isBeingEdited.current) { 99 | return; 100 | } 101 | 102 | const object = editableProxies[selected].object; 103 | 104 | (transform as Matrix4).decompose( 105 | object.position, 106 | object.quaternion, 107 | object.scale 108 | ); 109 | }, 110 | (state) => state.editables[selected].properties.transform 111 | ); 112 | 113 | return () => void unsub(); 114 | }, [editableProxies, selected]); 115 | 116 | // set up viewport shading modes 117 | const [renderMaterials, setRenderMaterials] = useState<{ 118 | [id: string]: Material | Material[]; 119 | }>({}); 120 | 121 | useLayoutEffect(() => { 122 | if (!sceneProxy) { 123 | return; 124 | } 125 | 126 | const renderMaterials: { 127 | [id: string]: Material | Material[]; 128 | } = {}; 129 | 130 | sceneProxy.traverse((object) => { 131 | const mesh = object as Mesh; 132 | if (mesh.isMesh && !mesh.userData.helper) { 133 | renderMaterials[mesh.id] = mesh.material; 134 | } 135 | }); 136 | 137 | setRenderMaterials(renderMaterials); 138 | 139 | return () => { 140 | Object.entries(renderMaterials).forEach(([id, material]) => { 141 | (sceneProxy.getObjectById( 142 | Number.parseInt(id) 143 | ) as Mesh).material = material; 144 | }); 145 | }; 146 | }, [sceneProxy]); 147 | 148 | useLayoutEffect(() => { 149 | if (!sceneProxy) { 150 | return; 151 | } 152 | 153 | sceneProxy.traverse((object) => { 154 | const mesh = object as Mesh; 155 | if (mesh.isMesh && !mesh.userData.helper) { 156 | let material; 157 | switch (viewportShading) { 158 | case 'wireframe': 159 | mesh.material = new MeshBasicMaterial({ 160 | wireframe: true, 161 | color: 'black', 162 | }); 163 | break; 164 | case 'flat': 165 | // it is possible that renderMaterials hasn't updated yet 166 | if (!renderMaterials[mesh.id]) { 167 | return; 168 | } 169 | material = new MeshBasicMaterial(); 170 | if (renderMaterials[mesh.id].hasOwnProperty('color')) { 171 | material.color = (renderMaterials[mesh.id] as any).color; 172 | } 173 | if (renderMaterials[mesh.id].hasOwnProperty('map')) { 174 | material.map = (renderMaterials[mesh.id] as any).map; 175 | } 176 | if (renderMaterials[mesh.id].hasOwnProperty('vertexColors')) { 177 | material.vertexColors = (renderMaterials[ 178 | mesh.id 179 | ] as any).vertexColors; 180 | } 181 | mesh.material = material; 182 | break; 183 | case 'solid': 184 | // it is possible that renderMaterials hasn't updated yet 185 | if (!renderMaterials[mesh.id]) { 186 | return; 187 | } 188 | material = new MeshPhongMaterial(); 189 | if (renderMaterials[mesh.id].hasOwnProperty('color')) { 190 | material.color = (renderMaterials[mesh.id] as any).color; 191 | } 192 | if (renderMaterials[mesh.id].hasOwnProperty('map')) { 193 | material.map = (renderMaterials[mesh.id] as any).map; 194 | } 195 | if (renderMaterials[mesh.id].hasOwnProperty('vertexColors')) { 196 | material.vertexColors = (renderMaterials[ 197 | mesh.id 198 | ] as any).vertexColors; 199 | } 200 | mesh.material = material; 201 | break; 202 | case 'rendered': 203 | mesh.material = renderMaterials[mesh.id]; 204 | } 205 | } 206 | }); 207 | }, [viewportShading, renderMaterials, sceneProxy]); 208 | 209 | if (!sceneProxy) { 210 | return null; 211 | } 212 | 213 | return ( 214 | <> 215 | 216 | {selected && ( 217 | { 223 | setEditableTransform( 224 | selected, 225 | editableProxies[selected].object.matrix.clone() 226 | ); 227 | }} 228 | onDraggingChange={(event) => (isBeingEdited.current = event.value)} 229 | /> 230 | )} 231 | {Object.values(editableProxies).map(({ portal }) => portal)} 232 | 233 | ); 234 | }; 235 | 236 | export default ProxyManager; 237 | -------------------------------------------------------------------------------- /src/components/ReferenceWindow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useLayoutEffect, useRef, VFC } from 'react'; 2 | import { useEditorStore } from '../store'; 3 | import shallow from 'zustand/shallow'; 4 | import { WebGLRenderer } from 'three'; 5 | import useMeasure from 'react-use-measure'; 6 | 7 | interface ReferenceWindowProps { 8 | height: number; 9 | } 10 | 11 | const ReferenceWindow: VFC = ({ height }) => { 12 | const canvasRef = useRef(null); 13 | 14 | const [gl] = useEditorStore((state) => [state.gl], shallow); 15 | const [ref, bounds] = useMeasure(); 16 | 17 | useLayoutEffect(() => { 18 | if (gl) { 19 | ref(gl?.domElement); 20 | } 21 | }, [gl, ref]); 22 | 23 | useEffect(() => { 24 | let animationHandle: number; 25 | const draw = (gl: WebGLRenderer) => () => { 26 | animationHandle = requestAnimationFrame(draw(gl)); 27 | 28 | if (!gl.domElement) { 29 | return; 30 | } 31 | 32 | const width = (gl.domElement.width / gl.domElement.height) * height; 33 | 34 | const ctx = canvasRef.current!.getContext('2d')!; 35 | 36 | // https://stackoverflow.com/questions/17861447/html5-canvas-drawimage-how-to-apply-antialiasing 37 | ctx.imageSmoothingQuality = 'high'; 38 | 39 | ctx.fillStyle = 'white'; 40 | ctx.fillRect(0, 0, width, height); 41 | ctx.drawImage(gl.domElement, 0, 0, width, height); 42 | }; 43 | 44 | if (gl) { 45 | draw(gl)(); 46 | } 47 | 48 | return () => { 49 | cancelAnimationFrame(animationHandle); 50 | }; 51 | }, [gl, height]); 52 | 53 | return gl?.domElement ? ( 54 |
55 | 64 |
65 | ) : null; 66 | }; 67 | 68 | export default ReferenceWindow; 69 | -------------------------------------------------------------------------------- /src/components/SceneOutlinePanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, VFC } from 'react'; 2 | import { EditableType, useEditorStore } from '../store'; 3 | import shallow from 'zustand/shallow'; 4 | import { Button as ButtonImpl, ButtonProps, Group } from 'reakit'; 5 | import { IconType } from '@react-icons/all-files'; 6 | import { BsFillCollectionFill } from '@react-icons/all-files/bs/BsFillCollectionFill'; 7 | import { GiLightProjector } from '@react-icons/all-files/gi/GiLightProjector'; 8 | import { BiSun } from '@react-icons/all-files/bi/BiSun'; 9 | import { GiCube } from '@react-icons/all-files/gi/GiCube'; 10 | import { GiLightBulb } from '@react-icons/all-files/gi/GiLightBulb'; 11 | import { BsCameraVideoFill } from '@react-icons/all-files/bs/BsCameraVideoFill'; 12 | import { Heading, Button } from './elements'; 13 | 14 | interface ObjectButtonProps extends ButtonProps { 15 | objectName: string; 16 | editableType: EditableType; 17 | selected: string | null; 18 | } 19 | 20 | const ObjectButton: VFC = ({ 21 | objectName, 22 | editableType, 23 | selected, 24 | ...props 25 | }) => { 26 | let icon: ReactElement; 27 | switch (editableType) { 28 | case 'group': 29 | icon = ; 30 | break; 31 | case 'mesh': 32 | icon = ; 33 | break; 34 | case 'pointLight': 35 | icon = ; 36 | break; 37 | case 'spotLight': 38 | icon = ; 39 | break; 40 | case 'directionalLight': 41 | icon = ; 42 | break; 43 | case 'perspectiveCamera': 44 | case 'orthographicCamera': 45 | icon = ; 46 | } 47 | 48 | return ( 49 | 58 | {icon} 59 | {objectName} 60 | 61 | ); 62 | }; 63 | 64 | const SceneOutlinePanel: VFC = () => { 65 | const [ 66 | editablesSnapshot, 67 | selected, 68 | setSelected, 69 | createSnapshot, 70 | ] = useEditorStore( 71 | (state) => [ 72 | state.editablesSnapshot, 73 | state.selected, 74 | state.setSelected, 75 | state.createSnapshot, 76 | ], 77 | shallow 78 | ); 79 | 80 | if (editablesSnapshot === null) { 81 | return null; 82 | } 83 | 84 | return ( 85 |
86 | Outline 87 | 91 | {Object.entries(editablesSnapshot).map( 92 | ([name, editable]) => 93 | editable.role === 'active' && ( 94 | { 100 | setSelected(name); 101 | }} 102 | /> 103 | ) 104 | )} 105 | 106 |
107 | 115 |
116 |
117 | ); 118 | }; 119 | 120 | export default SceneOutlinePanel; 121 | -------------------------------------------------------------------------------- /src/components/TransformControls.tsx: -------------------------------------------------------------------------------- 1 | import { Object3D, Event } from 'three'; 2 | import React, { forwardRef, useLayoutEffect, useEffect, useMemo } from 'react'; 3 | import { ReactThreeFiber, useThree, Overwrite } from 'react-three-fiber'; 4 | import { TransformControls as TransformControlsImpl } from 'three/examples/jsm/controls/TransformControls'; 5 | import { OrbitControls } from '@react-three/drei'; 6 | 7 | type R3fTransformControls = Overwrite< 8 | ReactThreeFiber.Object3DNode< 9 | TransformControlsImpl, 10 | typeof TransformControlsImpl 11 | >, 12 | { target?: ReactThreeFiber.Vector3 } 13 | >; 14 | 15 | export interface TransformControlsProps extends R3fTransformControls { 16 | object: Object3D; 17 | orbitControlsRef?: React.MutableRefObject; 18 | onObjectChange?: (event: Event) => void; 19 | onDraggingChange?: (event: Event) => void; 20 | } 21 | 22 | const TransformControls = forwardRef( 23 | ( 24 | { 25 | children, 26 | object, 27 | orbitControlsRef, 28 | onObjectChange, 29 | onDraggingChange, 30 | ...props 31 | }: TransformControlsProps, 32 | ref 33 | ) => { 34 | const { camera, gl, invalidate } = useThree(); 35 | const controls = useMemo( 36 | () => new TransformControlsImpl(camera, gl.domElement), 37 | [camera, gl.domElement] 38 | ); 39 | 40 | useLayoutEffect(() => { 41 | controls.attach(object); 42 | 43 | return () => void controls.detach(); 44 | }, [object, controls]); 45 | 46 | useEffect(() => { 47 | controls?.addEventListener?.('change', invalidate); 48 | return () => controls?.removeEventListener?.('change', invalidate); 49 | }, [controls, invalidate]); 50 | 51 | useEffect(() => { 52 | const callback = (event: Event) => { 53 | if (orbitControlsRef && orbitControlsRef.current) { 54 | orbitControlsRef.current.enabled = !event.value; 55 | } 56 | }; 57 | 58 | if (controls) { 59 | controls.addEventListener!('dragging-changed', callback); 60 | } 61 | 62 | return () => { 63 | controls.removeEventListener!('dragging-changed', callback); 64 | }; 65 | }, [controls, orbitControlsRef]); 66 | 67 | useEffect(() => { 68 | if (onObjectChange) { 69 | controls.addEventListener('objectChange', onObjectChange); 70 | } 71 | 72 | return () => { 73 | if (onObjectChange) { 74 | controls.removeEventListener('objectChange', onObjectChange); 75 | } 76 | }; 77 | }, [onObjectChange, controls]); 78 | 79 | useEffect(() => { 80 | if (onDraggingChange) { 81 | controls.addEventListener('dragging-changed', onDraggingChange); 82 | } 83 | 84 | return () => { 85 | if (onDraggingChange) { 86 | controls.removeEventListener('dragging-changed', onDraggingChange); 87 | } 88 | }; 89 | }, [controls, onDraggingChange]); 90 | 91 | return ; 92 | } 93 | ); 94 | 95 | export default TransformControls; 96 | -------------------------------------------------------------------------------- /src/components/TransformControlsModeSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { VFC } from 'react'; 2 | import { GiResize } from '@react-icons/all-files/gi/GiResize'; 3 | import { GiMove } from '@react-icons/all-files/gi/GiMove'; 4 | import { GiClockwiseRotation } from '@react-icons/all-files/gi/GiClockwiseRotation'; 5 | import { TransformControlsMode } from '../store'; 6 | import { CompactModeSelect } from './elements'; 7 | 8 | export interface TransformControlsModeSelectProps { 9 | value: TransformControlsMode; 10 | onChange: (value: TransformControlsMode) => void; 11 | } 12 | 13 | const TransformControlsModeSelect: VFC = ({ 14 | value, 15 | onChange, 16 | }) => ( 17 | , 25 | }, 26 | { 27 | option: 'rotate', 28 | label: 'Tool: Rotate', 29 | icon: , 30 | }, 31 | { 32 | option: 'scale', 33 | label: 'Tool: Scale', 34 | icon: , 35 | }, 36 | ]} 37 | /> 38 | ); 39 | 40 | export default TransformControlsModeSelect; 41 | -------------------------------------------------------------------------------- /src/components/TransformControlsSpaceSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { VFC } from 'react'; 2 | import { TransformControlsSpace } from '../store'; 3 | import { CompactModeSelect } from './elements'; 4 | import { BiGlobe } from '@react-icons/all-files/bi/BiGlobe'; 5 | import { BiCube } from '@react-icons/all-files/bi/BiCube'; 6 | 7 | export interface TransformControlsSpaceSelectProps { 8 | value: TransformControlsSpace; 9 | onChange: (value: TransformControlsSpace) => void; 10 | } 11 | 12 | const TransformControlsSpaceSelect: VFC = ({ 13 | value, 14 | onChange, 15 | }) => ( 16 | , 24 | }, 25 | { 26 | option: 'local', 27 | label: 'Space: Local', 28 | icon: , 29 | }, 30 | ]} 31 | /> 32 | ); 33 | 34 | export default TransformControlsSpaceSelect; 35 | -------------------------------------------------------------------------------- /src/components/UI.tsx: -------------------------------------------------------------------------------- 1 | import React, { VFC } from 'react'; 2 | import TransformControlsModeSelect from './TransformControlsModeSelect'; 3 | import { useEditorStore } from '../store'; 4 | import shallow from 'zustand/shallow'; 5 | import ReferenceWindow from './ReferenceWindow'; 6 | import { saveAs } from 'file-saver'; 7 | import { Vector3 } from 'three'; 8 | import { RiFocus3Line } from '@react-icons/all-files/ri/RiFocus3Line'; 9 | import { GiPocketBow } from '@react-icons/all-files/gi/GiPocketBow'; 10 | import { AiFillEye } from '@react-icons/all-files/ai/AiFillEye'; 11 | import TransformControlsSpaceSelect from './TransformControlsSpaceSelect'; 12 | import ViewportShadingSelect from './ViewportShadingSelect'; 13 | import SceneOutlinePanel from './SceneOutlinePanel'; 14 | import PropertiesPanel from './PropertiesPanel'; 15 | import { IconButton, Button, SettingsButton } from './elements'; 16 | import ViewportSettings from './ViewportSettings'; 17 | 18 | const UI: VFC = () => { 19 | const [ 20 | transformControlsMode, 21 | transformControlsSpace, 22 | viewportShading, 23 | setTransformControlsMode, 24 | setTransformControlsSpace, 25 | setViewportShading, 26 | setEditorOpen, 27 | setEditableTransform, 28 | ] = useEditorStore( 29 | (state) => [ 30 | state.transformControlsMode, 31 | state.transformControlsSpace, 32 | state.viewportShading, 33 | state.setTransformControlsMode, 34 | state.setTransformControlsSpace, 35 | state.setViewportShading, 36 | state.setEditorOpen, 37 | state.setEditableTransform, 38 | ], 39 | shallow 40 | ); 41 | 42 | return ( 43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 |
51 |
52 | setTransformControlsMode(value)} 55 | /> 56 |
57 |
58 | 62 |
63 |
64 | 68 |
69 |
70 | } 73 | onClick={() => { 74 | const orbitControls = useEditorStore.getState() 75 | .orbitControlsRef?.current; 76 | const selected = useEditorStore.getState().selected; 77 | let focusObject; 78 | 79 | if (selected) { 80 | focusObject = useEditorStore.getState() 81 | .editablesSnapshot![selected].proxyObject; 82 | } 83 | 84 | if (orbitControls && focusObject) { 85 | focusObject.getWorldPosition( 86 | orbitControls.target as Vector3 87 | ); 88 | } 89 | }} 90 | /> 91 |
92 |
93 | } 96 | onClick={() => { 97 | const camera = useEditorStore.getState().orbitControlsRef 98 | ?.current?.object; 99 | const selected = useEditorStore.getState().selected; 100 | 101 | let proxyObject; 102 | 103 | if (selected) { 104 | proxyObject = useEditorStore.getState() 105 | .editablesSnapshot![selected].proxyObject; 106 | 107 | if (proxyObject && camera) { 108 | const direction = new Vector3(); 109 | const position = camera.position.clone(); 110 | 111 | camera.getWorldDirection(direction); 112 | proxyObject.position.set(0, 0, 0); 113 | proxyObject.lookAt(direction); 114 | 115 | proxyObject.parent!.worldToLocal(position); 116 | proxyObject.position.copy(position); 117 | 118 | proxyObject.updateMatrix(); 119 | 120 | setEditableTransform( 121 | selected, 122 | proxyObject.matrix.clone() 123 | ); 124 | } 125 | } 126 | }} 127 | /> 128 |
129 |
130 | } label="Viewport settings"> 131 | 132 | 133 |
134 |
135 |
136 | 137 |
138 |
139 | 140 | {/* Bottom-left corner*/} 141 | 147 | 148 | {/* Bottom-right corner */} 149 | 161 |
162 |
163 | 164 |
165 |
166 |
167 | ); 168 | }; 169 | 170 | export default UI; 171 | -------------------------------------------------------------------------------- /src/components/ViewportSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { VFC } from 'react'; 2 | import { useEditorStore } from '../store'; 3 | import shallow from 'zustand/shallow'; 4 | import { Checkbox, FormControl } from './elements'; 5 | 6 | const ViewportShadingSettings: VFC = () => { 7 | const [ 8 | showOverlayIcons, 9 | showGrid, 10 | showAxes, 11 | setShowOverlayIcons, 12 | setShowGrid, 13 | setShowAxes, 14 | ] = useEditorStore( 15 | (state) => [ 16 | state.showOverlayIcons, 17 | state.showGrid, 18 | state.showAxes, 19 | state.setShowOverlayIcons, 20 | state.setShowGrid, 21 | state.setShowAxes, 22 | ], 23 | shallow 24 | ); 25 | 26 | return ( 27 |
28 | 29 | setShowOverlayIcons(!showOverlayIcons)} 33 | > 34 | Show overlay icons 35 | 36 | 37 | 38 | setShowGrid(!showGrid)} 42 | > 43 | Show grid 44 | 45 | 46 | 47 | setShowAxes(!showAxes)} 51 | > 52 | Show axes 53 | 54 | 55 |
56 | ); 57 | }; 58 | 59 | export default ViewportShadingSettings; 60 | -------------------------------------------------------------------------------- /src/components/ViewportShadingSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { VFC } from 'react'; 2 | import { GiIceCube } from '@react-icons/all-files/gi/GiIceCube'; 3 | import { BiCube } from '@react-icons/all-files/bi/BiCube'; 4 | import { FaCube } from '@react-icons/all-files/fa/FaCube'; 5 | import { GiCube } from '@react-icons/all-files/gi/GiCube'; 6 | import { ViewportShading } from '../store'; 7 | import { CompactModeSelect } from './elements'; 8 | import ViewportShadingSettings from './ViewportShadingSettings'; 9 | 10 | export interface ViewportShadingSelectProps { 11 | value: ViewportShading; 12 | onChange: (value: ViewportShading) => void; 13 | } 14 | 15 | const ViewportShadingSelect: VFC = ({ 16 | value, 17 | onChange, 18 | }) => ( 19 | , 27 | }, 28 | { 29 | option: 'flat', 30 | label: 'Display: Flat', 31 | icon: , 32 | }, 33 | { 34 | option: 'solid', 35 | label: 'Display: Solid', 36 | icon: , 37 | }, 38 | { 39 | option: 'rendered', 40 | label: 'Display: Rendered', 41 | icon: , 42 | }, 43 | ]} 44 | settingsPanel={} 45 | /> 46 | ); 47 | 48 | export default ViewportShadingSelect; 49 | -------------------------------------------------------------------------------- /src/components/ViewportShadingSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { VFC } from 'react'; 2 | import { useEditorStore } from '../store'; 3 | import shallow from 'zustand/shallow'; 4 | import EnvironmentPreview from './EnvironmentPreview'; 5 | import { Checkbox, FormControl, Heading } from './elements'; 6 | 7 | const ViewportShadingSettings: VFC = () => { 8 | const [ 9 | hdrPaths, 10 | selectedHdr, 11 | useHdrAsBackground, 12 | setSelectedHdr, 13 | setUseHdrAsBackground, 14 | ] = useEditorStore( 15 | (state) => [ 16 | state.hdrPaths, 17 | state.selectedHdr, 18 | state.useHdrAsBackground, 19 | state.setSelectedHdr, 20 | state.setUseHdrAsBackground, 21 | ], 22 | shallow 23 | ); 24 | 25 | return ( 26 |
27 | Environment 28 |
29 |
30 | { 34 | setSelectedHdr(null); 35 | }} 36 | /> 37 | {hdrPaths.map((hdrPath) => ( 38 | { 43 | setSelectedHdr(hdrPath); 44 | }} 45 | /> 46 | ))} 47 |
48 | 49 | setUseHdrAsBackground(!useHdrAsBackground)} 52 | > 53 | Use as background 54 | 55 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default ViewportShadingSettings; 62 | -------------------------------------------------------------------------------- /src/components/editable.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | ComponentProps, 3 | forwardRef, 4 | JSXElementConstructor, 5 | RefAttributes, 6 | useLayoutEffect, 7 | useRef, 8 | } from 'react'; 9 | import { 10 | DirectionalLight, 11 | Euler, 12 | Group, 13 | Matrix4, 14 | Mesh, 15 | OrthographicCamera, 16 | PerspectiveCamera, 17 | PointLight, 18 | Quaternion, 19 | SpotLight, 20 | Vector3, 21 | } from 'three'; 22 | import { EditableType, useEditorStore } from '../store'; 23 | import shallow from 'zustand/shallow'; 24 | import mergeRefs from 'react-merge-refs'; 25 | 26 | interface Elements { 27 | group: Group; 28 | mesh: Mesh; 29 | spotLight: SpotLight; 30 | directionalLight: DirectionalLight; 31 | perspectiveCamera: PerspectiveCamera; 32 | orthographicCamera: OrthographicCamera; 33 | pointLight: PointLight; 34 | } 35 | 36 | const editable = < 37 | T extends JSXElementConstructor | EditableType, 38 | U extends EditableType 39 | >( 40 | Component: T, 41 | type: U 42 | ) => 43 | forwardRef( 44 | ( 45 | { 46 | uniqueName, 47 | position, 48 | rotation, 49 | scale, 50 | ...props 51 | }: ComponentProps & { 52 | uniqueName: string; 53 | } & RefAttributes, 54 | ref 55 | ) => { 56 | const objectRef = useRef(); 57 | 58 | const [addEditable, removeEditable] = useEditorStore( 59 | (state) => [state.addEditable, state.removeEditable], 60 | shallow 61 | ); 62 | 63 | const transformDeps: string[] = []; 64 | 65 | ['x', 'y', 'z'].forEach((axis) => { 66 | transformDeps.push( 67 | props[`position-${axis}`], 68 | props[`rotation-${axis}`], 69 | props[`scale-${axis}`] 70 | ); 71 | }); 72 | 73 | useLayoutEffect(() => { 74 | // calculate initial properties before adding the editable 75 | const pos: Vector3 = position 76 | ? new Vector3(...position) 77 | : new Vector3(); 78 | const rot: Vector3 = rotation 79 | ? new Vector3(...rotation) 80 | : new Vector3(); 81 | const scal: Vector3 = scale 82 | ? new Vector3(...scale) 83 | : new Vector3(1, 1, 1); 84 | 85 | ['x', 'y', 'z'].forEach((axis, index) => { 86 | if (props[`position-${axis}`]) 87 | pos.setComponent(index, props[`position-${axis}`]); 88 | if (props[`rotation-${axis}`]) 89 | rot.setComponent(index, props[`rotation-${axis}`]); 90 | if (props[`scale-${axis}`]) 91 | scal.setComponent(index, props[`scale-${axis}`]); 92 | }); 93 | 94 | const quaternion = new Quaternion().setFromEuler( 95 | new Euler().setFromVector3(rot) 96 | ); 97 | 98 | addEditable(type, uniqueName, { 99 | transform: new Matrix4().compose(pos, quaternion, scal), 100 | }); 101 | 102 | return () => { 103 | removeEditable(uniqueName); 104 | }; 105 | // eslint-disable-next-line react-hooks/exhaustive-deps 106 | }, [ 107 | addEditable, 108 | position, 109 | removeEditable, 110 | rotation, 111 | scale, 112 | uniqueName, 113 | 114 | // nasty 115 | // eslint-disable-next-line react-hooks/exhaustive-deps 116 | ...transformDeps, 117 | ]); 118 | 119 | useLayoutEffect(() => { 120 | const object = objectRef.current!; 121 | // source of truth is .position, .quaternion and .scale, not the matrix, so we have to do this instead of setting the matrix 122 | useEditorStore 123 | .getState() 124 | .editables[uniqueName].properties.transform.decompose( 125 | object.position, 126 | object.quaternion, 127 | object.scale 128 | ); 129 | 130 | const unsub = useEditorStore.subscribe( 131 | (transform: Matrix4 | null) => { 132 | if (transform) { 133 | useEditorStore 134 | .getState() 135 | .editables[uniqueName].properties.transform.decompose( 136 | object.position, 137 | object.quaternion, 138 | object.scale 139 | ); 140 | } 141 | }, 142 | (state) => state.editables[uniqueName].properties.transform 143 | ); 144 | 145 | return () => { 146 | unsub(); 147 | }; 148 | }, [uniqueName]); 149 | 150 | return ( 151 | // @ts-ignore 152 | 161 | ); 162 | } 163 | ); 164 | 165 | const createEditable = (type: T) => 166 | editable(type, type); 167 | 168 | editable.group = createEditable('group'); 169 | editable.mesh = createEditable('mesh'); 170 | editable.spotLight = createEditable('spotLight'); 171 | editable.directionalLight = createEditable('directionalLight'); 172 | editable.pointLight = createEditable('pointLight'); 173 | editable.perspectiveCamera = createEditable('perspectiveCamera'); 174 | editable.orthographicCamera = createEditable('orthographicCamera'); 175 | 176 | export default editable; 177 | -------------------------------------------------------------------------------- /src/components/elements/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { Button as ButtonImpl, ButtonProps } from 'reakit'; 3 | 4 | export { ButtonProps }; 5 | 6 | const Button = forwardRef((props, ref) => { 7 | return ( 8 | 14 | ); 15 | }); 16 | 17 | export default Button; 18 | -------------------------------------------------------------------------------- /src/components/elements/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { Checkbox as CheckboxImpl, CheckboxProps } from 'reakit'; 3 | import { useFormControlContext } from './FormControl'; 4 | 5 | export { CheckboxProps }; 6 | 7 | const Checkbox = forwardRef( 8 | ({ children, ...props }, ref) => { 9 | const id = useFormControlContext(); 10 | 11 | return ( 12 |
13 |
14 | 21 |
22 |
23 | 26 |
27 |
28 | ); 29 | } 30 | ); 31 | 32 | export default Checkbox; 33 | -------------------------------------------------------------------------------- /src/components/elements/Code.tsx: -------------------------------------------------------------------------------- 1 | import React, { VFC } from 'react'; 2 | import Highlight, { defaultProps } from 'prism-react-renderer'; 3 | import theme from 'prism-react-renderer/themes/github'; 4 | 5 | export interface CodeProps { 6 | children: string; 7 | block?: boolean; 8 | } 9 | 10 | const Code: VFC = ({ children, block }) => { 11 | return ( 12 | 13 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 14 | 20 | {tokens.map((line, i) => ( 21 |
25 | {line.map((token, key) => ( 26 | 27 | ))} 28 |
29 | ))} 30 |
31 | )} 32 |
33 | ); 34 | }; 35 | export default Code; 36 | -------------------------------------------------------------------------------- /src/components/elements/CompactModeSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, ReactNode, VFC } from 'react'; 2 | import { Group, Button } from 'reakit'; 3 | import { FiChevronDown } from '@react-icons/all-files/fi/FiChevronDown'; 4 | import { IconType } from '@react-icons/all-files'; 5 | import { 6 | Tooltip, 7 | TooltipReference, 8 | usePopoverState, 9 | useTooltipState, 10 | PopoverDisclosure, 11 | Popover, 12 | } from '.'; 13 | 14 | interface OptionButtonProps