├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── .stylelintrc ├── LICENSE ├── README.md ├── assets └── gltf.svg ├── dist ├── aframe-inspector.js ├── aframe-inspector.js.map ├── aframe-inspector.min.js ├── aframe-inspector.min.js.LICENSE.txt └── aframe-inspector.min.js.map ├── examples ├── 360video.html ├── colors.html ├── controllers.html ├── embedded-zoom.html ├── embedded.html ├── empty.html ├── index.html └── supercraft.html ├── index.html ├── package-lock.json ├── package.json ├── src ├── components │ ├── AwesomeIcon.js │ ├── Collapsible.js │ ├── EntityRepresentation.js │ ├── Main.js │ ├── __tests__ │ │ └── Collapsible.test.js │ ├── components │ │ ├── AddComponent.js │ │ ├── CommonComponents.js │ │ ├── Component.js │ │ ├── ComponentsContainer.js │ │ ├── DefaultComponents.js │ │ ├── Mixins.js │ │ ├── PropertyRow.js │ │ └── Sidebar.js │ ├── modals │ │ ├── Modal.js │ │ ├── ModalHelp.js │ │ ├── ModalSponsor.js │ │ └── ModalTextures.js │ ├── scenegraph │ │ ├── Entity.js │ │ ├── SceneGraph.js │ │ └── Toolbar.js │ ├── viewport │ │ ├── CameraToolbar.js │ │ ├── TransformToolbar.js │ │ └── ViewportHUD.js │ └── widgets │ │ ├── BooleanWidget.js │ │ ├── ColorWidget.js │ │ ├── InputWidget.js │ │ ├── NumberWidget.js │ │ ├── SelectWidget.js │ │ ├── TextureWidget.js │ │ ├── Vec2Widget.js │ │ ├── Vec3Widget.js │ │ ├── Vec4Widget.js │ │ └── index.js ├── index.js ├── lib │ ├── EditorControls.js │ ├── Events.js │ ├── TransformControls.js │ ├── assetsLoader.js │ ├── assetsUtils.js │ ├── cameras.js │ ├── config.js │ ├── entity.js │ ├── history.js │ ├── raycaster.js │ ├── shortcuts.js │ ├── utils.js │ └── viewport.js └── style │ ├── components.styl │ ├── entity.styl │ ├── help.styl │ ├── index.styl │ ├── lib.styl │ ├── scenegraph.styl │ ├── select.styl │ ├── textureModal.styl │ ├── viewport.styl │ └── widgets.styl └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "testing": { 4 | "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] 5 | } 6 | }, 7 | "presets": [["@babel/preset-react", { "runtime": "automatic" }]] 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/lib/vendor/ 2 | src/components/__tests__/ 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:react/jsx-runtime", "standard"], 3 | "rules": { 4 | "multiline-ternary": "off", 5 | "no-console": "off", 6 | "no-lone-blocks": "off", 7 | "no-var": "off", 8 | "object-shorthand": "off", 9 | "no-useless-return": "off", 10 | "prefer-const": "off", 11 | "react/jsx-indent-props": [2, 2], 12 | "semi": [2, "always"], 13 | "space-before-function-paren": "off" 14 | }, 15 | "env": { 16 | "browser": true, 17 | "es2021": true, 18 | "node": true 19 | }, 20 | "globals": { 21 | "AFRAME": true, 22 | "THREE": true 23 | }, 24 | "parser": "@babel/eslint-parser", 25 | "parserOptions": { 26 | "ecmaFeatures": { 27 | "jsx": true 28 | }, 29 | "ecmaVersion": "latest", 30 | "sourceType": "module" 31 | }, 32 | "plugins": [ 33 | "react" 34 | ], 35 | "settings": { 36 | "react": { 37 | "version": "detect" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test Cases 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | permissions: 10 | contents: read 11 | jobs: 12 | test: 13 | name: Test Cases 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: ['20.x'] 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v4 21 | 22 | - name: Use Node.js ${{ matrix['node-version'] }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix['node-version'] }} 26 | cache: 'npm' 27 | cache-dependency-path: 'package-lock.json' 28 | 29 | - name: Install dependencies 30 | run: npm install 31 | 32 | - name: Test Cases 33 | run: npm run test:ci 34 | 35 | - name: Check Lint 36 | run: npm run lint 37 | 38 | - name: Check Build 39 | run: npm run dist 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .cache 3 | gh-pages/ 4 | node_modules 5 | npm-debug.log* 6 | build/ 7 | *.sw[pomn] 8 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | src/lib/TransformControls.js 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "plugins": [ 4 | "stylelint-order" 5 | ], 6 | "rules": { 7 | "selector-type-no-unknown": [true, 8 | "ignoreTypes": ["a-scene", "a-entity"] 9 | ], 10 | "order/properties-alphabetical-order": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright © 2018 A-Frame authors. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A-Frame Inspector 2 | 3 | A visual inspector tool for [A-Frame](https://aframe.io) scenes. Just hit 4 | ` + + i` on any A-Frame scene to open up the Inspector. 5 | 6 | - [Documentation / Guide](https://aframe.io/docs/master/introduction/visual-inspector-and-dev-tools.html) 7 | - [Example](https://aframe.io/aframe-inspector/examples/) 8 | 9 | Also check out: 10 | 11 | - [A-Frame Watcher](https://github.com/supermedium/aframe-watcher) - Companion server to sync changes to HTML files. 12 | 13 | ![Inspector Preview](https://user-images.githubusercontent.com/674727/50159991-fa540c80-028c-11e9-87f1-72c54e08d808.png) 14 | 15 | ## Using the Inspector 16 | 17 | ### Keyboard Shortcut 18 | 19 | A-Frame comes with a **keyboard shortcut** to inject the inspector. Just open 20 | up any A-Frame scene (running at least A-Frame v0.3.0) and press **` + 21 | + i`** to inject the inspector, just like you would use a DOM inspector: 22 | 23 | ### Specifying Inspector Build 24 | 25 | This is done with the `inspector` component. By default, this is set on the 26 | scene already. If we want, we can specify a specific build of the Inspector to 27 | inject by passing a URL. For debugging: 28 | 29 | ```html 30 | 31 | 32 | 33 | ``` 34 | 35 | To use the master branch of the Inspector: 36 | 37 | ```html 38 | 39 | 40 | ``` 41 | 42 | ## Local Development 43 | 44 | ```bash 45 | git clone git@github.com:aframevr/aframe-inspector.git 46 | cd aframe-inspector 47 | npm install 48 | npm start 49 | ``` 50 | 51 | Then navigate to __[http://localhost:3333/examples/](http://localhost:3333/examples/)__ 52 | 53 | ## Self-hosting the sample-assets directory 54 | 55 | The textures modal is using https://aframe.io/sample-assets/dist/images.json 56 | to get the available textures. 57 | The GitHub repository for those assets is https://github.com/aframevr/sample-assets 58 | 59 | If you want to self-host this directory, do the following: 60 | 61 | ```bash 62 | cd examples 63 | git clone git@github.com:aframevr/sample-assets.git 64 | ``` 65 | 66 | edit `index.html` and define before any script tag this global variable: 67 | 68 | ```html 69 | 70 | ``` 71 | 72 | ## Config overrides 73 | 74 | Since A-Frame 1.7.0, the inspector perspective camera position is kept in sync with the A-Frame 75 | active camera. This means you can move around the scene, toggle the inspector and you will be at the same position. 76 | If you want to disable that behavior, you can do that by defining a global variable like this: 77 | 78 | ```html 79 | 82 | ``` 83 | -------------------------------------------------------------------------------- /assets/gltf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | image/svg+xml 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 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 | -------------------------------------------------------------------------------- /dist/aframe-inspector.min.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! clipboard-copy. MIT License. Feross Aboukhadijeh */ 2 | 3 | /** 4 | * @license React 5 | * react-dom.production.min.js 6 | * 7 | * Copyright (c) Facebook, Inc. and its affiliates. 8 | * 9 | * This source code is licensed under the MIT license found in the 10 | * LICENSE file in the root directory of this source tree. 11 | */ 12 | 13 | /** 14 | * @license React 15 | * react-jsx-runtime.production.min.js 16 | * 17 | * Copyright (c) Facebook, Inc. and its affiliates. 18 | * 19 | * This source code is licensed under the MIT license found in the 20 | * LICENSE file in the root directory of this source tree. 21 | */ 22 | 23 | /** 24 | * @license React 25 | * react.production.min.js 26 | * 27 | * Copyright (c) Facebook, Inc. and its affiliates. 28 | * 29 | * This source code is licensed under the MIT license found in the 30 | * LICENSE file in the root directory of this source tree. 31 | */ 32 | 33 | /** 34 | * @license React 35 | * scheduler.production.min.js 36 | * 37 | * Copyright (c) Facebook, Inc. and its affiliates. 38 | * 39 | * This source code is licensed under the MIT license found in the 40 | * LICENSE file in the root directory of this source tree. 41 | */ 42 | 43 | /** @license React v16.13.1 44 | * react-is.production.min.js 45 | * 46 | * Copyright (c) Facebook, Inc. and its affiliates. 47 | * 48 | * This source code is licensed under the MIT license found in the 49 | * LICENSE file in the root directory of this source tree. 50 | */ 51 | -------------------------------------------------------------------------------- /examples/360video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 360 Video 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 21 | 27 | 28 | 31 | 32 | 33 | 34 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /examples/colors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example Scene 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /examples/controllers.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Controllers 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 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 | 53 | 54 | 55 | 56 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /examples/embedded-zoom.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Inspector Test - Embedded (Bad) 6 | 7 | 20 | 21 | 22 |
23 |
24 | 25 | 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 | 53 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 67 | 68 | 69 | 70 | 71 | 72 | 73 |
74 |
75 | 76 | 77 | -------------------------------------------------------------------------------- /examples/embedded.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Inspector Test - Embedded 6 | 7 | 25 | 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 | 53 | 54 | 55 | 56 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /examples/empty.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example Scene 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example Scene 6 | 7 | 8 | 9 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /examples/supercraft.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aframe-inspector", 3 | "version": "1.7.0", 4 | "description": "A visual inspector tool for A-Frame.", 5 | "main": "dist/aframe-inspector.min.js", 6 | "scripts": { 7 | "build": "webpack --progress", 8 | "deploy": "npm run ghpages", 9 | "dist": "npm run dist:max && npm run dist:min", 10 | "dist:max": "npm run build", 11 | "dist:min": "cross-env MINIFY=true npm run build", 12 | "ghpages": "npm run preghpages && gh-pages -d gh-pages", 13 | "lint": "npm run lintfile src/", 14 | "lint:css": "stylelint src/css/main.css", 15 | "lintfile": "eslint", 16 | "preghpages": "npm run dist && shx rm -rf gh-pages && shx mkdir gh-pages && shx cp -r assets dist examples index.html gh-pages && shx sed -i http://localhost:3333 .. gh-pages/examples/index.html", 17 | "prepare": "husky", 18 | "prepublish": "npm run dist", 19 | "prettier": "prettier --write 'src/**/*.js'", 20 | "start": "webpack serve --progress -d eval-source-map", 21 | "test": "BABEL_ENV=testing jest --watch", 22 | "test:ci": "BABEL_ENV=testing jest" 23 | }, 24 | "repository": "aframevr/aframe-inspector", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@fortawesome/free-solid-svg-icons": "^6.5.1", 28 | "clipboard-copy": "^4.0.1", 29 | "clsx": "^2.1.0", 30 | "lodash.debounce": "^4.0.8", 31 | "prop-types": "^15.8.1", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-select": "^5.8.0", 35 | "three": "0.168.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "^7.24.0", 39 | "@babel/eslint-parser": "^7.23.10", 40 | "@babel/preset-env": "^7.24.0", 41 | "@babel/preset-react": "^7.23.3", 42 | "autoprefixer": "^10.4.17", 43 | "babel-jest": "^29.7.0", 44 | "babel-loader": "^9.1.3", 45 | "cross-env": "^7.0.3", 46 | "css-loader": "^6.10.0", 47 | "eslint": "^8.57.0", 48 | "eslint-config-standard": "^17.1.0", 49 | "eslint-plugin-react": "^7.33.2", 50 | "gh-pages": "^6.3.0", 51 | "husky": "^9.1.7", 52 | "jest": "^29.7.0", 53 | "lint-staged": "^15.4.1", 54 | "postcss-loader": "^8.1.1", 55 | "prettier": "^3.2.5", 56 | "react-test-renderer": "^18.2.0", 57 | "shx": "^0.3.4", 58 | "style-loader": "^3.3.4", 59 | "stylelint": "^16.2.1", 60 | "stylelint-config-standard": "^36.0.0", 61 | "stylelint-order": "^6.0.4", 62 | "stylus": "^0.62.0", 63 | "stylus-loader": "^8.1.0", 64 | "webpack": "^5.91.0", 65 | "webpack-cli": "^5.1.4", 66 | "webpack-dev-server": "^5.0.4" 67 | }, 68 | "keywords": [ 69 | "3d", 70 | "aframe", 71 | "editor", 72 | "inspector", 73 | "three.js", 74 | "tool", 75 | "unity", 76 | "vr", 77 | "virtualreality", 78 | "webvr", 79 | "wysiwyg" 80 | ], 81 | "lint-staged": { 82 | "*.js": "prettier --write" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/AwesomeIcon.js: -------------------------------------------------------------------------------- 1 | /* 2 | Use instead of from @fortawesome/react-fontawesome 3 | Using FontAwesomeIcon component adds 66 kB minified to the bundle. 4 | Our AwesomeIcon does the same but less than 2 kB minified. 5 | svg-inline--fa class has been added to lib.styl 6 | */ 7 | import React from 'react'; 8 | import PropTypes from 'prop-types'; 9 | 10 | function asIcon(icon) { 11 | const width = icon[0]; 12 | const height = icon[1]; 13 | const vectorData = icon[4]; 14 | let element; 15 | 16 | if (Array.isArray(vectorData)) { 17 | element = ( 18 | 19 | {vectorData.map((pathData, index) => ( 20 | 21 | ))} 22 | 23 | ); 24 | } else { 25 | element = ; 26 | } 27 | 28 | return { 29 | width: width, 30 | height: height, 31 | icon: element 32 | }; 33 | } 34 | 35 | export class AwesomeIcon extends React.Component { 36 | static propTypes = { 37 | icon: PropTypes.object.isRequired 38 | }; 39 | 40 | render() { 41 | const { width, height, icon } = asIcon(this.props.icon.icon); 42 | return ( 43 | 49 | {icon} 50 | 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/components/Collapsible.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import clsx from 'clsx'; 4 | 5 | export default class Collapsible extends React.Component { 6 | static propTypes = { 7 | className: PropTypes.string, 8 | collapsed: PropTypes.bool, 9 | children: PropTypes.oneOfType([PropTypes.array, PropTypes.element]) 10 | .isRequired, 11 | id: PropTypes.string 12 | }; 13 | 14 | static defaultProps = { 15 | collapsed: false 16 | }; 17 | 18 | constructor(props) { 19 | super(props); 20 | this.state = { 21 | collapsed: this.props.collapsed 22 | }; 23 | } 24 | 25 | toggleVisibility = (event) => { 26 | // Don't collapse if we click on actions like clipboard 27 | if (event.target.nodeName === 'A') return; 28 | this.setState({ collapsed: !this.state.collapsed }); 29 | }; 30 | 31 | render() { 32 | const rootClassNames = { 33 | collapsible: true, 34 | component: true, 35 | collapsed: this.state.collapsed 36 | }; 37 | if (this.props.className) { 38 | rootClassNames[this.props.className] = true; 39 | } 40 | const rootClasses = clsx(rootClassNames); 41 | 42 | const contentClasses = clsx({ 43 | content: true, 44 | hide: this.state.collapsed 45 | }); 46 | 47 | return ( 48 |
49 |
50 |
51 | {this.props.children[0]} 52 |
53 |
{this.props.children[1]}
54 |
55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/EntityRepresentation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | faCamera, 5 | faCube, 6 | faFont, 7 | faLightbulb 8 | } from '@fortawesome/free-solid-svg-icons'; 9 | import { AwesomeIcon } from './AwesomeIcon'; 10 | 11 | const ICONS = { 12 | camera: ( 13 | 14 | 15 | 16 | ), 17 | mesh: ( 18 | 19 | 20 | 21 | ), 22 | light: ( 23 | 24 | 25 | 26 | ), 27 | text: ( 28 | 29 | 30 | 31 | ) 32 | }; 33 | 34 | export default class EntityRepresentation extends React.Component { 35 | static propTypes = { 36 | entity: PropTypes.object, 37 | onDoubleClick: PropTypes.func 38 | }; 39 | 40 | render() { 41 | const entity = this.props.entity; 42 | const onDoubleClick = this.props.onDoubleClick; 43 | 44 | if (!entity) { 45 | return null; 46 | } 47 | 48 | // Icons. 49 | const icons = []; 50 | for (let objType in ICONS) { 51 | if (!entity.getObject3D(objType)) { 52 | continue; 53 | } 54 | icons.push( {ICONS[objType]}); 55 | } 56 | 57 | // Name. 58 | let entityName = entity.id; 59 | let type = 'id'; 60 | if (!entity.isScene && !entityName && entity.getAttribute('class')) { 61 | entityName = entity.getAttribute('class').split(' ')[0]; 62 | type = 'class'; 63 | } else if (!entity.isScene && !entityName && entity.getAttribute('mixin')) { 64 | entityName = entity.getAttribute('mixin').split(' ')[0]; 65 | type = 'mixin'; 66 | } 67 | 68 | return ( 69 | 70 | 71 | {'<' + entity.tagName.toLowerCase()} 72 | 73 | {entityName && ( 74 | 75 |  {entityName} 76 | 77 | )} 78 | {icons.length > 0 && {icons}} 79 | {'>'} 80 | 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/Main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { faPlus } from '@fortawesome/free-solid-svg-icons'; 3 | import { AwesomeIcon } from './AwesomeIcon'; 4 | import Events from '../lib/Events'; 5 | import ComponentsSidebar from './components/Sidebar'; 6 | import ModalTextures from './modals/ModalTextures'; 7 | import ModalHelp from './modals/ModalHelp'; 8 | import ModalSponsor from './modals/ModalSponsor'; 9 | import SceneGraph from './scenegraph/SceneGraph'; 10 | import CameraToolbar from './viewport/CameraToolbar'; 11 | import TransformToolbar from './viewport/TransformToolbar'; 12 | import ViewportHUD from './viewport/ViewportHUD'; 13 | 14 | THREE.ImageUtils.crossOrigin = ''; 15 | 16 | export default class Main extends React.Component { 17 | constructor(props) { 18 | super(props); 19 | 20 | this.state = { 21 | entity: null, 22 | inspectorEnabled: true, 23 | isHelpOpen: false, 24 | isModalSponsorOpen: false, 25 | isModalTexturesOpen: false, 26 | sceneEl: AFRAME.scenes[0], 27 | visible: { 28 | scenegraph: true, 29 | attributes: true 30 | } 31 | }; 32 | 33 | Events.on('togglesidebar', (event) => { 34 | if (event.which === 'all') { 35 | if (this.state.visible.scenegraph || this.state.visible.attributes) { 36 | this.setState({ 37 | visible: { 38 | scenegraph: false, 39 | attributes: false 40 | } 41 | }); 42 | } else { 43 | this.setState({ 44 | visible: { 45 | scenegraph: true, 46 | attributes: true 47 | } 48 | }); 49 | } 50 | } else if (event.which === 'attributes') { 51 | this.setState((prevState) => ({ 52 | visible: { 53 | ...prevState.visible, 54 | attributes: !prevState.visible.attributes 55 | } 56 | })); 57 | } else if (event.which === 'scenegraph') { 58 | this.setState((prevState) => ({ 59 | visible: { 60 | ...prevState.visible, 61 | scenegraph: !prevState.visible.scenegraph 62 | } 63 | })); 64 | } 65 | }); 66 | } 67 | 68 | componentDidMount() { 69 | Events.on( 70 | 'opentexturesmodal', 71 | function (selectedTexture, textureOnClose) { 72 | this.setState({ 73 | selectedTexture: selectedTexture, 74 | isModalTexturesOpen: true, 75 | textureOnClose: textureOnClose 76 | }); 77 | }.bind(this) 78 | ); 79 | 80 | Events.on('entityselect', (entity) => { 81 | this.setState({ entity: entity }); 82 | }); 83 | 84 | Events.on('inspectortoggle', (enabled) => { 85 | this.setState({ inspectorEnabled: enabled }); 86 | }); 87 | 88 | Events.on('openhelpmodal', () => { 89 | this.setState({ isHelpOpen: true }); 90 | }); 91 | } 92 | 93 | onCloseHelpModal = (value) => { 94 | this.setState({ isHelpOpen: false }); 95 | }; 96 | 97 | onModalTextureOnClose = (value) => { 98 | this.setState({ isModalTexturesOpen: false }); 99 | if (this.state.textureOnClose) { 100 | this.state.textureOnClose(value); 101 | } 102 | }; 103 | 104 | openModalSponsor = () => { 105 | this.setState({ isModalSponsorOpen: true }); 106 | }; 107 | 108 | onCloseModalSponsor = () => { 109 | this.setState({ isModalSponsorOpen: false }); 110 | }; 111 | 112 | toggleEdit = () => { 113 | if (this.state.inspectorEnabled) { 114 | AFRAME.INSPECTOR.close(); 115 | } else { 116 | AFRAME.INSPECTOR.open(); 117 | } 118 | }; 119 | 120 | renderComponentsToggle() { 121 | if ( 122 | !this.state.inspectorEnabled || 123 | !this.state.entity || 124 | this.state.visible.attributes 125 | ) { 126 | return null; 127 | } 128 | return ( 129 | 139 | ); 140 | } 141 | 142 | renderSceneGraphToggle() { 143 | if (!this.state.inspectorEnabled || this.state.visible.scenegraph) { 144 | return null; 145 | } 146 | return ( 147 | 157 | ); 158 | } 159 | 160 | render() { 161 | const scene = this.state.sceneEl; 162 | const toggleButtonText = this.state.inspectorEnabled 163 | ? 'Back to Scene' 164 | : 'Inspect Scene'; 165 | 166 | return ( 167 |
168 | 169 | {toggleButtonText} 170 | 171 | {this.state.inspectorEnabled && ( 172 | 173 | 182 | Sponsor 183 | 184 | )} 185 | 186 | {this.renderSceneGraphToggle()} 187 | {this.renderComponentsToggle()} 188 | 189 |
193 | 198 | 199 |
200 | 201 | 202 | 203 |
204 | 205 |
206 | 210 |
211 |
212 | 213 | 217 | 221 | 226 |
227 | ); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/components/__tests__/Collapsible.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import Collapsible from '../Collapsible.js'; 5 | 6 | describe('Collapsible', () => { 7 | it('does not set class when not collapsed', () => { 8 | const tree = renderer 9 | .create( 10 | 11 |
12 |
13 | 14 | ) 15 | .toJSON(); 16 | expect(tree.children[1].props.className).not.toContain('hide'); 17 | }); 18 | 19 | it('sets class when collapsed', () => { 20 | const tree = renderer 21 | .create( 22 | 23 |
24 |
25 | 26 | ) 27 | .toJSON(); 28 | expect(tree.children[1].props.className).toContain('hide'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/components/AddComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Events from '../../lib/Events'; 4 | import Select from 'react-select'; 5 | 6 | export default class AddComponent extends React.Component { 7 | static propTypes = { 8 | entity: PropTypes.object 9 | }; 10 | 11 | constructor(props) { 12 | super(props); 13 | this.state = { value: null }; 14 | } 15 | 16 | /** 17 | * Add blank component. 18 | * If component is instanced, generate an ID. 19 | */ 20 | addComponent = (value) => { 21 | this.setState({ value: null }); 22 | 23 | let componentName = value.value; 24 | 25 | const entity = this.props.entity; 26 | 27 | if (AFRAME.components[componentName].multiple) { 28 | let id = prompt( 29 | `Provide an ID for this component (e.g., 'foo' for ${componentName}__foo).` 30 | ); 31 | if (id) { 32 | id = id 33 | .trim() 34 | .toLowerCase() 35 | .replace(/[^a-z0-9]/g, ''); 36 | // With the transform, id could be empty string, so we need to check again. 37 | } 38 | if (id) { 39 | componentName = `${componentName}__${id}`; 40 | } else { 41 | // If components already exist, be sure to suffix with an id, 42 | // if it's first one, use the component name without id. 43 | const numberOfComponents = Object.keys( 44 | this.props.entity.components 45 | ).filter(function (name) { 46 | return ( 47 | name === componentName || name.startsWith(`${componentName}__`) 48 | ); 49 | }).length; 50 | if (numberOfComponents > 0) { 51 | id = numberOfComponents + 1; 52 | componentName = `${componentName}__${id}`; 53 | } 54 | } 55 | } 56 | 57 | entity.setAttribute(componentName, ''); 58 | Events.emit('componentadd', { entity: entity, component: componentName }); 59 | }; 60 | 61 | /** 62 | * Component dropdown options. 63 | */ 64 | getComponentsOptions() { 65 | const usedComponents = Object.keys(this.props.entity.components); 66 | return Object.keys(AFRAME.components) 67 | .filter(function (componentName) { 68 | return ( 69 | AFRAME.components[componentName].multiple || 70 | usedComponents.indexOf(componentName) === -1 71 | ); 72 | }) 73 | .map(function (value) { 74 | return { value: value, label: value }; 75 | }) 76 | .toSorted(function (a, b) { 77 | return a.label === b.label ? 0 : a.label < b.label ? -1 : 1; 78 | }); 79 | } 80 | 81 | render() { 82 | const entity = this.props.entity; 83 | if (!entity) { 84 | return
; 85 | } 86 | 87 | const options = this.getComponentsOptions(); 88 | 89 | return ( 90 |
91 |

COMPONENTS

92 | 'No mixins found'} 76 | onChange={this.updateMixins.bind(this)} 77 | value={this.state.mixins} 78 | /> 79 | 80 |
81 |
82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/components/PropertyRow.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-prototype-builtins */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import clsx from 'clsx'; 5 | 6 | import BooleanWidget from '../widgets/BooleanWidget'; 7 | import ColorWidget from '../widgets/ColorWidget'; 8 | import InputWidget from '../widgets/InputWidget'; 9 | import NumberWidget from '../widgets/NumberWidget'; 10 | import SelectWidget from '../widgets/SelectWidget'; 11 | import TextureWidget from '../widgets/TextureWidget'; 12 | import Vec4Widget from '../widgets/Vec4Widget'; 13 | import Vec3Widget from '../widgets/Vec3Widget'; 14 | import Vec2Widget from '../widgets/Vec2Widget'; 15 | import { updateEntity } from '../../lib/entity'; 16 | 17 | export default class PropertyRow extends React.Component { 18 | static propTypes = { 19 | componentname: PropTypes.string.isRequired, 20 | data: PropTypes.oneOfType([ 21 | PropTypes.array.isRequired, 22 | PropTypes.bool.isRequired, 23 | PropTypes.number.isRequired, 24 | PropTypes.object.isRequired, 25 | PropTypes.string.isRequired 26 | ]), 27 | entity: PropTypes.object.isRequired, 28 | isSingle: PropTypes.bool.isRequired, 29 | name: PropTypes.string.isRequired, 30 | schema: PropTypes.object.isRequired 31 | }; 32 | 33 | constructor(props) { 34 | super(props); 35 | this.id = props.componentname + ':' + props.name; 36 | } 37 | 38 | getWidget() { 39 | const props = this.props; 40 | const isMap = 41 | props.componentname === 'material' && 42 | (props.name === 'envMap' || props.name === 'src'); 43 | let type = props.schema.type; 44 | if (props.componentname === 'animation' && props.name === 'loop') { 45 | // fix wrong number type for animation loop property 46 | type = 'boolean'; 47 | } 48 | 49 | const value = 50 | props.schema.type === 'selector' 51 | ? props.entity.getDOMAttribute(props.componentname)?.[props.name] 52 | : props.data; 53 | 54 | const widgetProps = { 55 | componentname: props.componentname, 56 | entity: props.entity, 57 | isSingle: props.isSingle, 58 | name: props.name, 59 | onChange: function (name, value) { 60 | updateEntity( 61 | props.entity, 62 | props.componentname, 63 | !props.isSingle ? props.name : '', 64 | value 65 | ); 66 | }, 67 | value: value 68 | }; 69 | const numberWidgetProps = { 70 | min: props.schema.hasOwnProperty('min') ? props.schema.min : -Infinity, 71 | max: props.schema.hasOwnProperty('max') ? props.schema.max : Infinity 72 | }; 73 | 74 | if (props.schema.oneOf && props.schema.oneOf.length > 0) { 75 | return ( 76 | 81 | ); 82 | } 83 | if (type === 'map' || isMap) { 84 | return ; 85 | } 86 | 87 | switch (type) { 88 | case 'number': { 89 | return ; 90 | } 91 | case 'int': { 92 | return ( 93 | 94 | ); 95 | } 96 | case 'vec2': { 97 | return ; 98 | } 99 | case 'vec3': { 100 | return ; 101 | } 102 | case 'vec4': { 103 | return ; 104 | } 105 | case 'color': { 106 | return ; 107 | } 108 | case 'boolean': { 109 | return ; 110 | } 111 | default: { 112 | if ( 113 | props.schema.type === 'string' && 114 | widgetProps.value && 115 | typeof widgetProps.value !== 'string' 116 | ) { 117 | // Allow editing a custom type like event-set component schema 118 | widgetProps.value = props.schema.stringify(widgetProps.value); 119 | } 120 | return ; 121 | } 122 | } 123 | } 124 | 125 | render() { 126 | const props = this.props; 127 | const value = 128 | props.schema.type === 'selector' 129 | ? props.entity.getDOMAttribute(props.componentname)?.[props.name] 130 | : JSON.stringify(props.data); 131 | const title = 132 | props.name + '\n - type: ' + props.schema.type + '\n - value: ' + value; 133 | 134 | const className = clsx({ 135 | propertyRow: true, 136 | propertyRowDefined: props.isSingle 137 | ? !!props.entity.getDOMAttribute(props.componentname) 138 | : props.name in 139 | (props.entity.getDOMAttribute(props.componentname) || {}) 140 | }); 141 | 142 | return ( 143 |
144 | 147 | {this.getWidget()} 148 |
149 | ); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/components/components/Sidebar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ComponentsContainer from './ComponentsContainer'; 4 | import Events from '../../lib/Events'; 5 | 6 | export default class Sidebar extends React.Component { 7 | static propTypes = { 8 | entity: PropTypes.object, 9 | visible: PropTypes.bool 10 | }; 11 | 12 | onComponentRemove = (detail) => { 13 | if (detail.entity !== this.props.entity) { 14 | return; 15 | } 16 | this.forceUpdate(); 17 | }; 18 | 19 | onComponentAdd = (detail) => { 20 | if (detail.entity !== this.props.entity) { 21 | return; 22 | } 23 | this.forceUpdate(); 24 | }; 25 | 26 | componentDidMount() { 27 | Events.on('componentremove', this.onComponentRemove); 28 | Events.on('componentadd', this.onComponentAdd); 29 | } 30 | 31 | componentWillUnmount() { 32 | Events.off('componentremove', this.onComponentRemove); 33 | Events.off('componentadd', this.onComponentAdd); 34 | } 35 | 36 | render() { 37 | const entity = this.props.entity; 38 | const visible = this.props.visible; 39 | if (entity && visible) { 40 | return ( 41 | 44 | ); 45 | } else { 46 | return
; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/modals/Modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class Modal extends React.Component { 5 | static propTypes = { 6 | id: PropTypes.string, 7 | children: PropTypes.oneOfType([PropTypes.array, PropTypes.element]) 8 | .isRequired, 9 | isOpen: PropTypes.bool, 10 | extraCloseKeyCode: PropTypes.number, 11 | closeOnClickOutside: PropTypes.bool, 12 | onClose: PropTypes.func, 13 | title: PropTypes.string 14 | }; 15 | 16 | static defaultProps = { 17 | closeOnClickOutside: true 18 | }; 19 | 20 | constructor(props) { 21 | super(props); 22 | this.state = { isOpen: this.props.isOpen }; 23 | this.self = React.createRef(); 24 | } 25 | 26 | componentDidMount() { 27 | document.addEventListener('keyup', this.handleGlobalKeydown); 28 | document.addEventListener('mousedown', this.handleGlobalMousedown); 29 | } 30 | 31 | handleGlobalKeydown = (event) => { 32 | if ( 33 | this.state.isOpen && 34 | (event.keyCode === 27 || 35 | (this.props.extraCloseKeyCode && 36 | event.keyCode === this.props.extraCloseKeyCode)) 37 | ) { 38 | this.close(); 39 | 40 | // Prevent closing the inspector 41 | event.stopPropagation(); 42 | } 43 | }; 44 | 45 | shouldClickDismiss = (event) => { 46 | var target = event.target; 47 | // This piece of code isolates targets which are fake clicked by things 48 | // like file-drop handlers 49 | if (target.tagName === 'INPUT' && target.type === 'file') { 50 | return false; 51 | } 52 | if (target === this.self.current || this.self.current.contains(target)) { 53 | return false; 54 | } 55 | return true; 56 | }; 57 | 58 | handleGlobalMousedown = (event) => { 59 | if ( 60 | this.props.closeOnClickOutside && 61 | this.state.isOpen && 62 | this.shouldClickDismiss(event) 63 | ) { 64 | if (typeof this.props.onClose === 'function') { 65 | this.props.onClose(); 66 | } 67 | } 68 | }; 69 | 70 | componentWillUnmount() { 71 | document.removeEventListener('keyup', this.handleGlobalKeydown); 72 | document.removeEventListener('mousedown', this.handleGlobalMousedown); 73 | } 74 | 75 | static getDerivedStateFromProps(props, state) { 76 | if (state.isOpen !== props.isOpen) { 77 | return { isOpen: props.isOpen }; 78 | } 79 | return null; 80 | } 81 | 82 | close = () => { 83 | this.setState({ isOpen: false }); 84 | if (this.props.onClose) { 85 | this.props.onClose(); 86 | } 87 | }; 88 | 89 | render() { 90 | return ( 91 |
95 |
96 |
97 | 98 | × 99 | 100 |

{this.props.title}

101 |
102 |
{this.props.children}
103 |
104 |
105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/components/modals/ModalHelp.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Modal from './Modal'; 4 | 5 | export default class ModalHelp extends React.Component { 6 | static propTypes = { 7 | isOpen: PropTypes.bool, 8 | onClose: PropTypes.func 9 | }; 10 | 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | isOpen: this.props.isOpen 15 | }; 16 | } 17 | 18 | static getDerivedStateFromProps(props, state) { 19 | if (state.isOpen !== props.isOpen) { 20 | return { isOpen: props.isOpen }; 21 | } 22 | return null; 23 | } 24 | 25 | onClose = () => { 26 | if (this.props.onClose) { 27 | this.props.onClose(); 28 | } 29 | }; 30 | 31 | render() { 32 | let shortcuts = [ 33 | [ 34 | { key: ['w'], description: 'Translate' }, 35 | { key: ['e'], description: 'Rotate' }, 36 | { key: ['r'], description: 'Scale' }, 37 | { key: ['d'], description: 'Duplicate selected entity' }, 38 | { key: ['f'], description: 'Focus on selected entity' }, 39 | { key: ['g'], description: 'Toggle grid visibility' }, 40 | { key: ['n'], description: 'Add new entity' }, 41 | { key: ['o'], description: 'Toggle local between global transform' }, 42 | { key: ['delete | backspace'], description: 'Delete selected entity' } 43 | ], 44 | [ 45 | { key: ['0'], description: 'Toggle panels' }, 46 | { key: ['1'], description: 'Perspective view' }, 47 | { key: ['2'], description: 'Left view' }, 48 | { key: ['3'], description: 'Right view' }, 49 | { key: ['4'], description: 'Top view' }, 50 | { key: ['5'], description: 'Bottom view' }, 51 | { key: ['6'], description: 'Back view' }, 52 | { key: ['7'], description: 'Front view' }, 53 | 54 | { key: ['ctrl | cmd', 'c'], description: 'Copy selected entity' }, 55 | { key: ['ctrl | cmd', 'v'], description: 'Paste entity' }, 56 | { key: ['h'], description: 'Show this help' }, 57 | { key: ['esc'], description: 'Unselect entity' }, 58 | { key: ['ctrl', 'alt', 'i'], description: 'Switch Edit and VR Modes' } 59 | ] 60 | ]; 61 | 62 | return ( 63 | 69 |
70 | {shortcuts.map(function (column, idx) { 71 | return ( 72 |
    73 | {column.map(function (shortcut) { 74 | return ( 75 |
  • 76 | {shortcut.key.map(function (key) { 77 | return ( 78 | 79 | {key} 80 | 81 | ); 82 | })} 83 | 84 | {shortcut.description} 85 | 86 |
  • 87 | ); 88 | })} 89 |
90 | ); 91 | })} 92 |
93 |
94 | ); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/components/modals/ModalSponsor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Modal from './Modal'; 4 | 5 | export default class ModalSponsor extends React.Component { 6 | static propTypes = { 7 | isOpen: PropTypes.bool.isRequired, 8 | onClose: PropTypes.func.isRequired 9 | }; 10 | 11 | render() { 12 | return ( 13 | 18 |
19 |

20 | The inspector is kept up to date by members of the community working 21 | on the aframe editor, a modified version of the inspector with 22 | additional features. 23 |

24 | 25 | 35 |

36 | If you like it, please consider supporting the project. 37 |

38 |
39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/scenegraph/Entity.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { 5 | faCaretDown, 6 | faCaretRight, 7 | faClone, 8 | faEye, 9 | faEyeSlash, 10 | faTrashAlt 11 | } from '@fortawesome/free-solid-svg-icons'; 12 | import { AwesomeIcon } from '../AwesomeIcon'; 13 | import clsx from 'clsx'; 14 | import { removeEntity, cloneEntity } from '../../lib/entity'; 15 | import EntityRepresentation from '../EntityRepresentation'; 16 | import Events from '../../lib/Events'; 17 | 18 | export default class Entity extends React.Component { 19 | static propTypes = { 20 | id: PropTypes.string, 21 | depth: PropTypes.number, 22 | entity: PropTypes.object, 23 | isExpanded: PropTypes.bool, 24 | isFiltering: PropTypes.bool, 25 | isSelected: PropTypes.bool, 26 | selectEntity: PropTypes.func, 27 | toggleExpandedCollapsed: PropTypes.func 28 | }; 29 | 30 | onClick = () => this.props.selectEntity(this.props.entity); 31 | 32 | onDoubleClick = () => Events.emit('objectfocus', this.props.entity.object3D); 33 | 34 | toggleVisibility = () => { 35 | const entity = this.props.entity; 36 | const visible = 37 | entity.tagName.toLowerCase() === 'a-scene' 38 | ? entity.object3D.visible 39 | : entity.getAttribute('visible'); 40 | entity.setAttribute('visible', !visible); 41 | }; 42 | 43 | render() { 44 | const isFiltering = this.props.isFiltering; 45 | const isExpanded = this.props.isExpanded; 46 | const entity = this.props.entity; 47 | const tagName = entity.tagName.toLowerCase(); 48 | 49 | // Clone and remove buttons if not a-scene. 50 | const cloneButton = 51 | tagName === 'a-scene' ? null : ( 52 | cloneEntity(entity)} 54 | title="Clone entity" 55 | className="button" 56 | > 57 | 58 | 59 | ); 60 | const removeButton = 61 | tagName === 'a-scene' ? null : ( 62 | { 64 | event.stopPropagation(); 65 | removeEntity(entity); 66 | }} 67 | title="Remove entity" 68 | className="button" 69 | > 70 | 71 | 72 | ); 73 | 74 | // Add spaces depending on depth. 75 | const pad = '    '.repeat(this.props.depth); 76 | let collapse; 77 | if (entity.children.length > 0 && !isFiltering) { 78 | collapse = ( 79 | this.props.toggleExpandedCollapsed(entity)} 81 | className="collapsespace" 82 | > 83 | {isExpanded ? ( 84 | 85 | ) : ( 86 | 87 | )} 88 | 89 | ); 90 | } else { 91 | collapse = ; 92 | } 93 | 94 | // Visibility button. 95 | const visible = 96 | tagName === 'a-scene' 97 | ? entity.object3D.visible 98 | : entity.getAttribute('visible'); 99 | const visibilityButton = ( 100 | 101 | {visible ? ( 102 | 103 | ) : ( 104 | 105 | )} 106 | 107 | ); 108 | 109 | // Class name. 110 | const className = clsx({ 111 | active: this.props.isSelected, 112 | entity: true, 113 | novisible: !visible, 114 | option: true 115 | }); 116 | 117 | return ( 118 |
119 | 120 | {visibilityButton} 121 | 125 | {collapse} 126 | 130 | 131 | 132 | {cloneButton} 133 | {removeButton} 134 | 135 |
136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/components/scenegraph/SceneGraph.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars, react/no-danger */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; 5 | import { AwesomeIcon } from '../AwesomeIcon'; 6 | import debounce from 'lodash.debounce'; 7 | 8 | import Entity from './Entity'; 9 | import Toolbar from './Toolbar'; 10 | import Events from '../../lib/Events'; 11 | 12 | export default class SceneGraph extends React.Component { 13 | static propTypes = { 14 | scene: PropTypes.object, 15 | selectedEntity: PropTypes.object, 16 | visible: PropTypes.bool 17 | }; 18 | 19 | static defaultProps = { 20 | selectedEntity: '' 21 | }; 22 | 23 | constructor(props) { 24 | super(props); 25 | this.state = { 26 | entities: [], 27 | expandedElements: new WeakMap([[props.scene, true]]), 28 | filter: '', 29 | filteredEntities: [], 30 | selectedIndex: -1 31 | }; 32 | 33 | this.updateFilteredEntities = debounce( 34 | this.updateFilteredEntities.bind(this), 35 | 100 36 | ); 37 | } 38 | 39 | onEntityUpdate = (detail) => { 40 | if (detail.component === 'mixin' || detail.component === 'visible') { 41 | this.rebuildEntityOptions(); 42 | } 43 | }; 44 | 45 | componentDidMount() { 46 | this.rebuildEntityOptions(); 47 | Events.on('entityidchange', this.rebuildEntityOptions); 48 | Events.on('entitycreated', this.rebuildEntityOptions); 49 | Events.on('entityclone', this.rebuildEntityOptions); 50 | Events.on('entityupdate', this.onEntityUpdate); 51 | } 52 | 53 | componentWillUnmount() { 54 | Events.off('entityidchange', this.rebuildEntityOptions); 55 | Events.off('entitycreated', this.rebuildEntityOptions); 56 | Events.off('entityclone', this.rebuildEntityOptions); 57 | Events.off('entityupdate', this.onEntityUpdate); 58 | } 59 | 60 | /** 61 | * Selected entity updated from somewhere else in the app. 62 | */ 63 | componentDidUpdate(prevProps) { 64 | if (prevProps.selectedEntity !== this.props.selectedEntity) { 65 | this.selectEntity(this.props.selectedEntity); 66 | } 67 | } 68 | 69 | selectEntity = (entity) => { 70 | let found = false; 71 | for (let i = 0; i < this.state.filteredEntities.length; i++) { 72 | const entityOption = this.state.filteredEntities[i]; 73 | if (entityOption.entity === entity) { 74 | this.setState({ selectedIndex: i }); 75 | setTimeout(() => { 76 | // wait 100ms to allow React to update the UI and create the node we're interested in 77 | const node = document.getElementById('sgnode' + i); 78 | const scrollableContainer = document.querySelector( 79 | '#scenegraph .outliner' 80 | ); 81 | if (!node || !scrollableContainer) return; 82 | const containerRect = scrollableContainer.getBoundingClientRect(); 83 | const nodeRect = node.getBoundingClientRect(); 84 | const isVisible = 85 | nodeRect.top >= containerRect.top && 86 | nodeRect.bottom <= containerRect.bottom; 87 | if (!isVisible) { 88 | node.scrollIntoView({ behavior: 'smooth' }); 89 | } 90 | }, 100); 91 | // Make sure selected value is visible in scenegraph 92 | this.expandToRoot(entity); 93 | Events.emit('entityselect', entity); 94 | found = true; 95 | } 96 | } 97 | 98 | if (!found) { 99 | this.setState({ selectedIndex: -1 }); 100 | } 101 | }; 102 | 103 | rebuildEntityOptions = () => { 104 | const entities = [{ depth: 0, entity: this.props.scene }]; 105 | 106 | function treeIterate(element, depth) { 107 | if (!element) { 108 | return; 109 | } 110 | depth += 1; 111 | 112 | for (let i = 0; i < element.children.length; i++) { 113 | let entity = element.children[i]; 114 | 115 | if ( 116 | entity.dataset.isInspector || 117 | !entity.isEntity || 118 | entity.isInspector || 119 | 'aframeInspector' in entity.dataset 120 | ) { 121 | continue; 122 | } 123 | 124 | entities.push({ 125 | entity: entity, 126 | depth: depth, 127 | id: 'sgnode' + entities.length 128 | }); 129 | 130 | treeIterate(entity, depth); 131 | } 132 | } 133 | treeIterate(this.props.scene, 0); 134 | 135 | this.setState({ 136 | entities: entities, 137 | filteredEntities: this.getFilteredEntities(this.state.filter, entities) 138 | }); 139 | }; 140 | 141 | selectIndex = (index) => { 142 | if (index >= 0 && index < this.state.entities.length) { 143 | this.selectEntity(this.state.entities[index].entity); 144 | } 145 | }; 146 | 147 | onFilterKeyUp = (event) => { 148 | if (event.keyCode === 27) { 149 | this.clearFilter(); 150 | } 151 | }; 152 | 153 | onKeyDown = (event) => { 154 | switch (event.keyCode) { 155 | case 37: // left 156 | case 38: // up 157 | case 39: // right 158 | case 40: // down 159 | event.preventDefault(); 160 | event.stopPropagation(); 161 | break; 162 | } 163 | }; 164 | 165 | onKeyUp = (event) => { 166 | if (this.props.selectedEntity === null) { 167 | return; 168 | } 169 | 170 | switch (event.keyCode) { 171 | case 37: // left 172 | if (this.isExpanded(this.props.selectedEntity)) { 173 | this.toggleExpandedCollapsed(this.props.selectedEntity); 174 | } 175 | break; 176 | case 38: // up 177 | this.selectIndex( 178 | this.previousExpandedIndexTo(this.state.selectedIndex) 179 | ); 180 | break; 181 | case 39: // right 182 | if (!this.isExpanded(this.props.selectedEntity)) { 183 | this.toggleExpandedCollapsed(this.props.selectedEntity); 184 | } 185 | break; 186 | case 40: // down 187 | this.selectIndex(this.nextExpandedIndexTo(this.state.selectedIndex)); 188 | break; 189 | } 190 | }; 191 | 192 | getFilteredEntities(filter, entities) { 193 | entities = entities || this.state.entities; 194 | if (!filter) { 195 | return entities; 196 | } 197 | return entities.filter((entityOption) => { 198 | return filterEntity(entityOption.entity, filter || this.state.filter); 199 | }); 200 | } 201 | 202 | isVisibleInSceneGraph = (x) => { 203 | let curr = x.parentNode; 204 | if (!curr) { 205 | return false; 206 | } 207 | while (curr?.isEntity) { 208 | if (!this.isExpanded(curr)) { 209 | return false; 210 | } 211 | curr = curr.parentNode; 212 | } 213 | return true; 214 | }; 215 | 216 | isExpanded = (x) => this.state.expandedElements.get(x) === true; 217 | 218 | toggleExpandedCollapsed = (x) => { 219 | this.setState({ 220 | expandedElements: this.state.expandedElements.set(x, !this.isExpanded(x)) 221 | }); 222 | }; 223 | 224 | expandToRoot = (x) => { 225 | // Expand element all the way to the scene element 226 | let curr = x.parentNode; 227 | while (curr !== undefined && curr.isEntity) { 228 | this.state.expandedElements.set(curr, true); 229 | curr = curr.parentNode; 230 | } 231 | this.setState({ expandedElements: this.state.expandedElements }); 232 | }; 233 | 234 | previousExpandedIndexTo = (i) => { 235 | for (let prevIter = i - 1; prevIter >= 0; prevIter--) { 236 | const prevEl = this.state.entities[prevIter].entity; 237 | if (this.isVisibleInSceneGraph(prevEl)) { 238 | return prevIter; 239 | } 240 | } 241 | return -1; 242 | }; 243 | 244 | nextExpandedIndexTo = (i) => { 245 | for ( 246 | let nextIter = i + 1; 247 | nextIter < this.state.entities.length; 248 | nextIter++ 249 | ) { 250 | const nextEl = this.state.entities[nextIter].entity; 251 | if (this.isVisibleInSceneGraph(nextEl)) { 252 | return nextIter; 253 | } 254 | } 255 | return -1; 256 | }; 257 | 258 | onChangeFilter = (evt) => { 259 | const filter = evt.target.value; 260 | this.setState({ filter: filter }); 261 | this.updateFilteredEntities(filter); 262 | }; 263 | 264 | updateFilteredEntities(filter) { 265 | this.setState({ 266 | filteredEntities: this.getFilteredEntities(filter) 267 | }); 268 | } 269 | 270 | clearFilter = () => { 271 | this.setState({ filter: '' }); 272 | this.updateFilteredEntities(''); 273 | }; 274 | 275 | renderEntities = () => { 276 | return this.state.filteredEntities.map((entityOption, idx) => { 277 | if ( 278 | !this.isVisibleInSceneGraph(entityOption.entity) && 279 | !this.state.filter 280 | ) { 281 | return null; 282 | } 283 | return ( 284 | 293 | ); 294 | }); 295 | }; 296 | 297 | render() { 298 | // To hide the SceneGraph we have to hide its parent too (#left-sidebar). 299 | if (!this.props.visible) { 300 | return null; 301 | } 302 | 303 | const clearFilter = this.state.filter ? ( 304 | 305 | 306 | 307 | ) : null; 308 | 309 | return ( 310 |
311 |
312 | 313 |
314 | 321 | {clearFilter} 322 | {!this.state.filter && } 323 |
324 |
325 |
331 | {this.renderEntities()} 332 |
333 |
334 | ); 335 | } 336 | } 337 | 338 | function filterEntity(entity, filter) { 339 | if (!filter) { 340 | return true; 341 | } 342 | 343 | // Check if the ID, tagName, class, selector includes the filter. 344 | if ( 345 | entity.id.toUpperCase().indexOf(filter.toUpperCase()) !== -1 || 346 | entity.tagName.toUpperCase().indexOf(filter.toUpperCase()) !== -1 || 347 | entity.classList.contains(filter) || 348 | entity.matches(filter) 349 | ) { 350 | return true; 351 | } 352 | 353 | return false; 354 | } 355 | -------------------------------------------------------------------------------- /src/components/scenegraph/Toolbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | faPlus, 4 | faPause, 5 | faPlay, 6 | faFloppyDisk, 7 | faQuestion 8 | } from '@fortawesome/free-solid-svg-icons'; 9 | import { AwesomeIcon } from '../AwesomeIcon'; 10 | import Events from '../../lib/Events'; 11 | import { saveBlob } from '../../lib/utils'; 12 | import GLTFIcon from '../../../assets/gltf.svg'; 13 | 14 | function filterHelpers(scene, visible) { 15 | scene.traverse((o) => { 16 | if (o.userData.source === 'INSPECTOR') { 17 | o.visible = visible; 18 | } 19 | }); 20 | } 21 | 22 | function getSceneName(scene) { 23 | return scene.id || slugify(window.location.host + window.location.pathname); 24 | } 25 | 26 | /** 27 | * Slugify the string removing non-word chars and spaces 28 | * @param {string} text String to slugify 29 | * @return {string} Slugified string 30 | */ 31 | function slugify(text) { 32 | return text 33 | .toString() 34 | .toLowerCase() 35 | .replace(/\s+/g, '-') // Replace spaces with - 36 | .replace(/[^\w-]+/g, '-') // Replace all non-word chars with - 37 | .replace(/--+/g, '-') // Replace multiple - with single - 38 | .replace(/^-+/, '') // Trim - from start of text 39 | .replace(/-+$/, ''); // Trim - from end of text 40 | } 41 | 42 | /** 43 | * Tools and actions. 44 | */ 45 | export default class Toolbar extends React.Component { 46 | constructor(props) { 47 | super(props); 48 | 49 | this.state = { 50 | isPlaying: false 51 | }; 52 | } 53 | 54 | exportSceneToGLTF() { 55 | const sceneName = getSceneName(AFRAME.scenes[0]); 56 | const scene = AFRAME.scenes[0].object3D; 57 | filterHelpers(scene, false); 58 | AFRAME.INSPECTOR.exporters.gltf.parse( 59 | scene, 60 | function (buffer) { 61 | filterHelpers(scene, true); 62 | const blob = new Blob([buffer], { type: 'application/octet-stream' }); 63 | saveBlob(blob, sceneName + '.glb'); 64 | }, 65 | function (error) { 66 | console.error(error); 67 | }, 68 | { binary: true } 69 | ); 70 | } 71 | 72 | addEntity() { 73 | Events.emit('entitycreate', { element: 'a-entity', components: {} }); 74 | } 75 | 76 | /** 77 | * Try to write changes with aframe-inspector-watcher. 78 | */ 79 | writeChanges = () => { 80 | const xhr = new XMLHttpRequest(); 81 | xhr.open('POST', 'http://localhost:51234/save'); 82 | xhr.onerror = () => { 83 | alert( 84 | 'aframe-watcher not running. This feature requires a companion service running locally. npm install aframe-watcher to save changes back to file. Read more at https://github.com/supermedium/aframe-watcher' 85 | ); 86 | }; 87 | xhr.setRequestHeader('Content-Type', 'application/json'); 88 | xhr.send(JSON.stringify(AFRAME.INSPECTOR.history.updates)); 89 | }; 90 | 91 | toggleScenePlaying = () => { 92 | if (this.state.isPlaying) { 93 | AFRAME.scenes[0].pause(); 94 | this.setState({ isPlaying: false }); 95 | AFRAME.scenes[0].isPlaying = true; 96 | document.getElementById('aframeInspectorMouseCursor').play(); 97 | return; 98 | } 99 | AFRAME.scenes[0].isPlaying = false; 100 | AFRAME.scenes[0].play(); 101 | this.setState({ isPlaying: true }); 102 | }; 103 | 104 | openHelpModal = () => { 105 | Events.emit('openhelpmodal'); 106 | }; 107 | 108 | render() { 109 | const watcherTitle = 'Write changes with aframe-watcher.'; 110 | 111 | return ( 112 | 154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/components/viewport/CameraToolbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Select from 'react-select'; 3 | import Events from '../../lib/Events'; 4 | 5 | const options = [ 6 | { 7 | value: 'perspective', 8 | event: 'cameraperspectivetoggle', 9 | payload: null, 10 | label: 'Perspective' 11 | }, 12 | { 13 | value: 'ortholeft', 14 | event: 'cameraorthographictoggle', 15 | payload: 'left', 16 | label: 'Left View' 17 | }, 18 | { 19 | value: 'orthoright', 20 | event: 'cameraorthographictoggle', 21 | payload: 'right', 22 | label: 'Right View' 23 | }, 24 | { 25 | value: 'orthotop', 26 | event: 'cameraorthographictoggle', 27 | payload: 'top', 28 | label: 'Top View' 29 | }, 30 | { 31 | value: 'orthobottom', 32 | event: 'cameraorthographictoggle', 33 | payload: 'bottom', 34 | label: 'Bottom View' 35 | }, 36 | { 37 | value: 'orthoback', 38 | event: 'cameraorthographictoggle', 39 | payload: 'back', 40 | label: 'Back View' 41 | }, 42 | { 43 | value: 'orthofront', 44 | event: 'cameraorthographictoggle', 45 | payload: 'front', 46 | label: 'Front View' 47 | } 48 | ]; 49 | 50 | function getOption(value) { 51 | return options.filter((opt) => opt.value === value)[0]; 52 | } 53 | 54 | export default class CameraToolbar extends React.Component { 55 | constructor(props) { 56 | super(props); 57 | this.state = { 58 | selectedCamera: 'perspective' 59 | }; 60 | this.justChangedCamera = false; 61 | } 62 | 63 | onCameraToggle = (data) => { 64 | if (this.justChangedCamera) { 65 | // Prevent recursion. 66 | this.justChangedCamera = false; 67 | return; 68 | } 69 | this.setState({ selectedCamera: data.value }); 70 | }; 71 | 72 | componentDidMount() { 73 | Events.on('cameratoggle', this.onCameraToggle); 74 | } 75 | 76 | componentWillUnmount() { 77 | Events.off('cameratoggle', this.onCameraToggle); 78 | } 79 | 80 | onChange(option) { 81 | this.justChangedCamera = true; 82 | this.setState({ selectedCamera: option.value }); 83 | Events.emit(option.event, option.payload); 84 | } 85 | 86 | render() { 87 | return ( 88 |
89 | 100 | 106 | 107 |
108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/components/viewport/ViewportHUD.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import EntityRepresentation from '../EntityRepresentation'; 3 | import Events from '../../lib/Events'; 4 | 5 | export default class ViewportHUD extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | hoveredEntity: null 10 | }; 11 | } 12 | 13 | onRaycasterMouseEnter = (el) => { 14 | this.setState({ hoveredEntity: el }); 15 | }; 16 | 17 | onRaycasterMouseLeave = (el) => { 18 | this.setState({ hoveredEntity: el }); 19 | }; 20 | 21 | componentDidMount() { 22 | Events.on('raycastermouseenter', this.onRaycasterMouseEnter); 23 | Events.on('raycastermouseleave', this.onRaycasterMouseLeave); 24 | } 25 | 26 | componentWillUnmount() { 27 | Events.off('raycastermouseenter', this.onRaycasterMouseEnter); 28 | Events.off('raycastermouseleave', this.onRaycasterMouseLeave); 29 | } 30 | 31 | render() { 32 | return ( 33 |
34 |

35 | 36 |

37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/widgets/BooleanWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class BooleanWidget extends React.Component { 5 | static propTypes = { 6 | componentname: PropTypes.string.isRequired, 7 | entity: PropTypes.object, 8 | name: PropTypes.string.isRequired, 9 | onChange: PropTypes.func, 10 | value: PropTypes.bool 11 | }; 12 | 13 | static defaultProps = { 14 | value: false 15 | }; 16 | 17 | constructor(props) { 18 | super(props); 19 | this.state = { value: this.props.value }; 20 | } 21 | 22 | componentDidUpdate(prevProps) { 23 | if (this.props.value !== prevProps.value) { 24 | this.setState({ value: this.props.value }); 25 | } 26 | } 27 | 28 | onChange = (e) => { 29 | var value = e.target.checked; 30 | this.setState({ value: value }); 31 | if (this.props.onChange) { 32 | this.props.onChange(this.props.name, value); 33 | } 34 | }; 35 | 36 | render() { 37 | var id = this.props.componentname + '.' + this.props.name; 38 | 39 | return ( 40 | 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/widgets/ColorWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class ColorWidget extends React.Component { 5 | static propTypes = { 6 | componentname: PropTypes.string.isRequired, 7 | entity: PropTypes.object, 8 | name: PropTypes.string.isRequired, 9 | onChange: PropTypes.func, 10 | value: PropTypes.string 11 | }; 12 | 13 | static defaultProps = { 14 | value: '#ffffff' 15 | }; 16 | 17 | constructor(props) { 18 | super(props); 19 | 20 | var value = this.props.value; 21 | this.color = new THREE.Color(); 22 | 23 | this.state = { 24 | value: value, 25 | pickerValue: this.getHexString(value) 26 | }; 27 | } 28 | 29 | setValue(value) { 30 | var pickerValue = this.getHexString(value); 31 | 32 | this.setState({ 33 | value: value, 34 | pickerValue: pickerValue 35 | }); 36 | 37 | if (this.props.onChange) { 38 | this.props.onChange(this.props.name, value); 39 | } 40 | } 41 | 42 | componentDidUpdate(prevProps) { 43 | if (this.props.value !== prevProps.value) { 44 | this.setState({ 45 | value: this.props.value, 46 | pickerValue: this.getHexString(this.props.value) 47 | }); 48 | } 49 | } 50 | 51 | getHexString(value) { 52 | return '#' + this.color.set(value).getHexString(); 53 | } 54 | 55 | onChange = (e) => { 56 | this.setValue(e.target.value); 57 | }; 58 | 59 | onKeyUp = (e) => { 60 | e.stopPropagation(); 61 | // if (e.keyCode === 13) 62 | this.setValue(e.target.value); 63 | }; 64 | 65 | onChangeText = (e) => { 66 | this.setState({ value: e.target.value }); 67 | }; 68 | 69 | render() { 70 | return ( 71 | 72 | 79 | 86 | 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/components/widgets/InputWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class InputWidget extends React.Component { 5 | static propTypes = { 6 | componentname: PropTypes.string, 7 | entity: PropTypes.object, 8 | name: PropTypes.string.isRequired, 9 | onChange: PropTypes.func, 10 | value: PropTypes.any 11 | }; 12 | 13 | constructor(props) { 14 | super(props); 15 | this.state = { value: this.props.value || '' }; 16 | } 17 | 18 | onChange = (e) => { 19 | var value = e.target.value; 20 | this.setState({ value: value }); 21 | if (this.props.onChange) { 22 | this.props.onChange(this.props.name, value); 23 | } 24 | }; 25 | 26 | componentDidUpdate(prevProps) { 27 | if (this.props.value !== prevProps.value) { 28 | this.setState({ value: this.props.value }); 29 | } 30 | } 31 | 32 | render() { 33 | return ( 34 | 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/widgets/NumberWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class NumberWidget extends React.Component { 5 | static propTypes = { 6 | componentname: PropTypes.string, 7 | entity: PropTypes.object, 8 | max: PropTypes.number, 9 | min: PropTypes.number, 10 | name: PropTypes.string, 11 | onChange: PropTypes.func, 12 | precision: PropTypes.number, 13 | step: PropTypes.number, 14 | value: PropTypes.number 15 | }; 16 | 17 | static defaultProps = { 18 | min: -Infinity, 19 | max: Infinity, 20 | value: 0, 21 | precision: 3, 22 | step: 1 23 | }; 24 | 25 | constructor(props) { 26 | super(props); 27 | this.state = { 28 | value: this.props.value, 29 | displayValue: 30 | typeof this.props.value === 'number' 31 | ? this.props.value.toFixed(this.props.precision) 32 | : '' 33 | }; 34 | this.input = React.createRef(); 35 | } 36 | 37 | componentDidMount() { 38 | this.distance = 0; 39 | this.onMouseDownValue = 0; 40 | this.prevPointer = [0, 0]; 41 | } 42 | 43 | onMouseMove = (event) => { 44 | const currentValue = parseFloat(this.state.value); 45 | const pointer = [event.clientX, event.clientY]; 46 | const delta = 47 | pointer[0] - this.prevPointer[0] - (pointer[1] - this.prevPointer[1]); 48 | this.distance += delta; 49 | 50 | // Add minimum tolerance to reduce unintentional drags when clicking on input. 51 | // if (Math.abs(delta) <= 2) { return; } 52 | 53 | let value = 54 | this.onMouseDownValue + 55 | ((this.distance / (event.shiftKey ? 5 : 50)) * this.props.step) / 2; 56 | value = Math.min(this.props.max, Math.max(this.props.min, value)); 57 | if (currentValue !== value) { 58 | this.setValue(value); 59 | } 60 | this.prevPointer = [event.clientX, event.clientY]; 61 | }; 62 | 63 | onMouseDown = (event) => { 64 | event.preventDefault(); 65 | this.distance = 0; 66 | this.onMouseDownValue = this.state.value; 67 | this.prevPointer = [event.clientX, event.clientY]; 68 | document.addEventListener('mousemove', this.onMouseMove, false); 69 | document.addEventListener('mouseup', this.onMouseUp, false); 70 | }; 71 | 72 | onMouseUp = () => { 73 | document.removeEventListener('mousemove', this.onMouseMove, false); 74 | document.removeEventListener('mouseup', this.onMouseUp, false); 75 | 76 | if (Math.abs(this.distance) < 2) { 77 | this.input.current.focus(); 78 | this.input.current.select(); 79 | } 80 | }; 81 | 82 | setValue(value) { 83 | if (value === this.state.value) return; 84 | 85 | if (value !== undefined) { 86 | if (this.props.precision === 0) { 87 | value = parseInt(value); 88 | } else { 89 | value = parseFloat(value); 90 | } 91 | 92 | // If we inadvertently typed a character in the field, set value to the previous value from props 93 | if (isNaN(value)) { 94 | value = this.props.value; 95 | } 96 | 97 | if (value < this.props.min) { 98 | value = this.props.min; 99 | } 100 | if (value > this.props.max) { 101 | value = this.props.max; 102 | } 103 | 104 | this.setState({ 105 | value: value, 106 | displayValue: value.toFixed(this.props.precision) 107 | }); 108 | 109 | if (this.props.onChange) { 110 | this.props.onChange(this.props.name, parseFloat(value.toFixed(5))); 111 | } 112 | } 113 | } 114 | 115 | componentDidUpdate(prevProps) { 116 | // This will be triggered typically when the element is changed directly with 117 | // element.setAttribute. 118 | 119 | // We use Object.is instead of === for comparison here so that comparing two NaN doesn't trigger an infinite update. 120 | // Object.is(NaN, NaN) is true, NaN === NaN is false 121 | if (!Object.is(this.props.value, prevProps.value)) { 122 | this.setState({ 123 | value: this.props.value, 124 | displayValue: this.props.value.toFixed(this.props.precision) 125 | }); 126 | } 127 | } 128 | 129 | onBlur = () => { 130 | this.setValue(parseFloat(this.input.current.value)); 131 | }; 132 | 133 | onChange = (e) => { 134 | this.setState({ value: e.target.value, displayValue: e.target.value }); 135 | }; 136 | 137 | onKeyDown = (event) => { 138 | event.stopPropagation(); 139 | 140 | // enter. 141 | if (event.keyCode === 13) { 142 | this.input.current.blur(); 143 | return; 144 | } 145 | 146 | // up. 147 | if (event.keyCode === 38) { 148 | this.setValue(parseFloat(this.state.value) + 0.01); 149 | return; 150 | } 151 | 152 | // down. 153 | if (event.keyCode === 40) { 154 | this.setValue(parseFloat(this.state.value) - 0.01); 155 | return; 156 | } 157 | }; 158 | 159 | render() { 160 | return ( 161 | 171 | ); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/components/widgets/SelectWidget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Select from 'react-select'; 4 | 5 | export default class SelectWidget extends React.Component { 6 | static propTypes = { 7 | isMulti: PropTypes.bool, 8 | name: PropTypes.string.isRequired, 9 | onChange: PropTypes.func, 10 | options: PropTypes.array.isRequired, 11 | value: PropTypes.oneOfType([ 12 | PropTypes.number, 13 | PropTypes.string, 14 | PropTypes.array 15 | ]).isRequired 16 | }; 17 | 18 | static defaultProps = { 19 | isMulti: false 20 | }; 21 | 22 | constructor(props) { 23 | super(props); 24 | if (this.props.isMulti) { 25 | const value = this.props.value; 26 | this.state = { 27 | value: value.map((choice) => ({ value: choice, label: choice })) 28 | }; 29 | } else { 30 | const value = this.props.value; 31 | this.state = { value: { value: value, label: value } }; 32 | } 33 | } 34 | 35 | onChange = (value) => { 36 | this.setState({ value: value }, () => { 37 | if (this.props.onChange) { 38 | this.props.onChange( 39 | this.props.name, 40 | this.props.isMulti ? value.map((option) => option.value) : value.value 41 | ); 42 | } 43 | }); 44 | }; 45 | 46 | componentDidUpdate(prevProps) { 47 | const props = this.props; 48 | if (props.value !== prevProps.value) { 49 | if (this.props.isMulti) { 50 | this.setState({ 51 | value: props.value.map((choice) => ({ value: choice, label: choice })) 52 | }); 53 | } else { 54 | this.setState({ 55 | value: { value: props.value, label: props.value } 56 | }); 57 | } 58 | } 59 | } 60 | 61 | render() { 62 | const options = this.props.options.map((value) => { 63 | return { value: value, label: value }; 64 | }); 65 | 66 | return ( 67 | 241 | 248 |
249 | ); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/components/widgets/Vec2Widget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import NumberWidget from './NumberWidget'; 5 | import { areVectorsEqual } from '../../lib/utils'; 6 | 7 | export default class Vec2Widget extends React.Component { 8 | static propTypes = { 9 | componentname: PropTypes.string, 10 | entity: PropTypes.object, 11 | onChange: PropTypes.func, 12 | value: PropTypes.object.isRequired 13 | }; 14 | 15 | constructor(props) { 16 | super(props); 17 | this.state = { 18 | x: props.value.x, 19 | y: props.value.y 20 | }; 21 | } 22 | 23 | onChange = (name, value) => { 24 | this.setState({ [name]: parseFloat(value.toFixed(5)) }, () => { 25 | if (this.props.onChange) { 26 | this.props.onChange(name, this.state); 27 | } 28 | }); 29 | }; 30 | 31 | componentDidUpdate() { 32 | const props = this.props; 33 | if (!areVectorsEqual(props.value, this.state)) { 34 | this.setState({ 35 | x: props.value.x, 36 | y: props.value.y 37 | }); 38 | } 39 | } 40 | 41 | render() { 42 | const widgetProps = { 43 | componentname: this.props.componentname, 44 | entity: this.props.entity, 45 | onChange: this.onChange 46 | }; 47 | 48 | return ( 49 |
50 | 51 | 52 |
53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/widgets/Vec3Widget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import NumberWidget from './NumberWidget'; 5 | import { areVectorsEqual } from '../../lib/utils'; 6 | 7 | export default class Vec3Widget extends React.Component { 8 | static propTypes = { 9 | componentname: PropTypes.string, 10 | entity: PropTypes.object, 11 | onChange: PropTypes.func, 12 | value: PropTypes.object.isRequired 13 | }; 14 | 15 | constructor(props) { 16 | super(props); 17 | this.state = { 18 | x: props.value.x, 19 | y: props.value.y, 20 | z: props.value.z 21 | }; 22 | } 23 | 24 | onChange = (name, value) => { 25 | this.setState({ [name]: parseFloat(value.toFixed(5)) }, () => { 26 | if (this.props.onChange) { 27 | this.props.onChange(name, this.state); 28 | } 29 | }); 30 | }; 31 | 32 | componentDidUpdate() { 33 | const props = this.props; 34 | if (!areVectorsEqual(props.value, this.state)) { 35 | this.setState({ 36 | x: props.value.x, 37 | y: props.value.y, 38 | z: props.value.z 39 | }); 40 | } 41 | } 42 | 43 | render() { 44 | const widgetProps = { 45 | componentname: this.props.componentname, 46 | entity: this.props.entity, 47 | onChange: this.onChange 48 | }; 49 | 50 | return ( 51 |
52 | 53 | 54 | 55 |
56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/widgets/Vec4Widget.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import NumberWidget from './NumberWidget'; 5 | import { areVectorsEqual } from '../../lib/utils'; 6 | 7 | export default class Vec4Widget extends React.Component { 8 | static propTypes = { 9 | componentname: PropTypes.string, 10 | entity: PropTypes.object, 11 | onChange: PropTypes.func, 12 | value: PropTypes.object.isRequired 13 | }; 14 | 15 | constructor(props) { 16 | super(props); 17 | this.state = { 18 | x: props.value.x, 19 | y: props.value.y, 20 | z: props.value.z, 21 | w: props.value.w 22 | }; 23 | } 24 | 25 | onChange = (name, value) => { 26 | this.setState({ [name]: parseFloat(value.toFixed(5)) }, () => { 27 | if (this.props.onChange) { 28 | this.props.onChange(name, this.state); 29 | } 30 | }); 31 | }; 32 | 33 | componentDidUpdate() { 34 | const props = this.props; 35 | if (!areVectorsEqual(props.value, this.state)) { 36 | this.setState({ 37 | x: props.value.x, 38 | y: props.value.y, 39 | z: props.value.z, 40 | w: props.value.w 41 | }); 42 | } 43 | } 44 | 45 | render() { 46 | const widgetProps = { 47 | componentname: this.props.componentname, 48 | entity: this.props.entity, 49 | onChange: this.onChange 50 | }; 51 | 52 | return ( 53 |
54 | 55 | 56 | 57 | 58 |
59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/widgets/index.js: -------------------------------------------------------------------------------- 1 | export { default as BooleanWidget } from './BooleanWidget'; 2 | export { default as ColorWidget } from './ColorWidget'; 3 | export { default as InputWidget } from './InputWidget'; 4 | export { default as NumberWidget } from './NumberWidget'; 5 | export { default as SelectWidget } from './SelectWidget'; 6 | export { default as TextureWidget } from './TextureWidget'; 7 | export { default as Vec4Widget } from './Vec4Widget'; 8 | export { default as Vec3Widget } from './Vec3Widget'; 9 | export { default as Vec2Widget } from './Vec2Widget'; 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import Events from './lib/Events'; 3 | import { Viewport } from './lib/viewport'; 4 | import { AssetsLoader } from './lib/assetsLoader'; 5 | import { Shortcuts } from './lib/shortcuts'; 6 | 7 | import Main from './components/Main'; 8 | import { initCameras } from './lib/cameras'; 9 | import { createEntity } from './lib/entity'; 10 | import { Config } from './lib/config'; 11 | import { GLTFExporter } from 'three/addons/exporters/GLTFExporter'; 12 | 13 | import './style/index.styl'; 14 | 15 | function Inspector(configOverrides) { 16 | this.assetsLoader = new AssetsLoader(); 17 | this.config = new Config(configOverrides); 18 | this.exporters = { gltf: new GLTFExporter() }; 19 | this.history = require('./lib/history'); 20 | this.isFirstOpen = true; 21 | this.modules = {}; 22 | this.opened = false; 23 | 24 | // Wait for stuff. 25 | const doInit = () => { 26 | if (!AFRAME.scenes.length) { 27 | setTimeout(() => { 28 | doInit(); 29 | }, 100); 30 | return; 31 | } 32 | 33 | this.sceneEl = AFRAME.scenes[0]; 34 | if (this.sceneEl.hasLoaded) { 35 | this.init(); 36 | return; 37 | } 38 | this.sceneEl.addEventListener('loaded', this.init.bind(this), { 39 | once: true 40 | }); 41 | }; 42 | doInit(); 43 | } 44 | 45 | Inspector.prototype = { 46 | init: function () { 47 | // Wait for camera. 48 | if (!this.sceneEl.camera) { 49 | this.sceneEl.addEventListener( 50 | 'camera-set-active', 51 | () => { 52 | this.init(); 53 | }, 54 | { once: true } 55 | ); 56 | return; 57 | } 58 | 59 | this.container = document.querySelector('.a-canvas'); 60 | initCameras(this); 61 | this.initUI(); 62 | }, 63 | 64 | initUI: function () { 65 | Shortcuts.init(this); 66 | this.initEvents(); 67 | 68 | this.selected = null; 69 | 70 | // Init React. 71 | const div = document.createElement('div'); 72 | div.id = 'aframeInspector'; 73 | div.setAttribute('data-aframe-inspector', 'app'); 74 | document.body.appendChild(div); 75 | const root = createRoot(div); 76 | root.render(
); 77 | 78 | this.scene = this.sceneEl.object3D; 79 | this.helpers = {}; 80 | this.sceneHelpers = new THREE.Scene(); 81 | this.sceneHelpers.userData.source = 'INSPECTOR'; 82 | this.sceneHelpers.visible = true; 83 | this.inspectorActive = false; 84 | 85 | this.viewport = new Viewport(this); 86 | 87 | this.sceneEl.object3D.traverse((node) => { 88 | this.addHelper(node); 89 | }); 90 | 91 | this.scene.add(this.sceneHelpers); 92 | this.open(); 93 | }, 94 | 95 | removeObject: function (object) { 96 | // Remove just the helper as the object will be deleted by A-Frame 97 | this.removeHelpers(object); 98 | Events.emit('objectremove', object); 99 | }, 100 | 101 | addHelper: function (object) { 102 | let helper; 103 | 104 | if (object instanceof THREE.Camera) { 105 | this.cameraHelper = helper = new THREE.CameraHelper(object); 106 | } else if (object instanceof THREE.PointLight) { 107 | helper = new THREE.PointLightHelper(object, 1); 108 | } else if (object instanceof THREE.DirectionalLight) { 109 | helper = new THREE.DirectionalLightHelper(object, 1); 110 | } else if (object instanceof THREE.SpotLight) { 111 | helper = new THREE.SpotLightHelper(object, 1); 112 | } else if (object instanceof THREE.HemisphereLight) { 113 | helper = new THREE.HemisphereLightHelper(object, 1); 114 | } else if (object instanceof THREE.SkinnedMesh) { 115 | helper = new THREE.SkeletonHelper(object); 116 | } else { 117 | // no helper for this object type 118 | return; 119 | } 120 | 121 | helper.visible = false; 122 | this.sceneHelpers.add(helper); 123 | this.helpers[object.uuid] = helper; 124 | // SkeletonHelper doesn't have an update method 125 | if (helper.update) { 126 | helper.update(); 127 | } 128 | }, 129 | 130 | removeHelpers: function (object) { 131 | object.traverse((node) => { 132 | const helper = this.helpers[node.uuid]; 133 | if (helper) { 134 | this.sceneHelpers.remove(helper); 135 | helper.dispose(); 136 | delete this.helpers[node.uuid]; 137 | Events.emit('helperremove', this.helpers[node.uuid]); 138 | } 139 | }); 140 | }, 141 | 142 | selectEntity: function (entity, emit) { 143 | this.selectedEntity = entity; 144 | if (entity) { 145 | this.select(entity.object3D); 146 | } else { 147 | this.select(null); 148 | } 149 | 150 | if (emit === undefined) { 151 | Events.emit('entityselect', entity); 152 | } 153 | 154 | // Update helper visibilities. 155 | for (const id in this.helpers) { 156 | this.helpers[id].visible = false; 157 | } 158 | 159 | if (entity === this.sceneEl) { 160 | return; 161 | } 162 | 163 | if (entity) { 164 | entity.object3D.traverse((node) => { 165 | if (this.helpers[node.uuid]) { 166 | this.helpers[node.uuid].visible = true; 167 | } 168 | }); 169 | } 170 | }, 171 | 172 | initEvents: function () { 173 | // Remove inspector component to properly unregister keydown listener when the inspector is loaded via a script tag, 174 | // otherwise the listener will be registered twice and we can't toggle the inspector from viewer mode with the shortcut. 175 | this.sceneEl.removeAttribute('inspector'); 176 | window.addEventListener('keydown', (evt) => { 177 | // Alt + Ctrl + i: Shorcut to toggle the inspector 178 | const shortcutPressed = 179 | evt.keyCode === 73 && 180 | ((evt.ctrlKey && evt.altKey) || evt.getModifierState('AltGraph')); 181 | if (shortcutPressed) { 182 | this.toggle(); 183 | } 184 | }); 185 | 186 | Events.on('entityselect', (entity) => { 187 | this.selectEntity(entity, false); 188 | }); 189 | 190 | Events.on('inspectortoggle', (active) => { 191 | this.inspectorActive = active; 192 | this.sceneHelpers.visible = this.inspectorActive; 193 | }); 194 | 195 | Events.on('entitycreate', (definition) => { 196 | createEntity(definition, (entity) => { 197 | this.selectEntity(entity); 198 | }); 199 | }); 200 | 201 | document.addEventListener('child-detached', (event) => { 202 | const entity = event.detail.el; 203 | AFRAME.INSPECTOR.removeObject(entity.object3D); 204 | }); 205 | }, 206 | 207 | selectById: function (id) { 208 | if (id === this.camera.id) { 209 | this.select(this.camera); 210 | return; 211 | } 212 | const object = this.scene.getObjectById(id); 213 | if (object) { 214 | this.select(object); 215 | } 216 | }, 217 | 218 | /** 219 | * Change to select object. 220 | */ 221 | select: function (object3D) { 222 | if (this.selected === object3D) { 223 | return; 224 | } 225 | this.selected = object3D; 226 | Events.emit('objectselect', object3D); 227 | }, 228 | 229 | deselect: function () { 230 | this.select(null); 231 | }, 232 | 233 | /** 234 | * Toggle the editor 235 | */ 236 | toggle: function () { 237 | if (this.opened) { 238 | this.close(); 239 | } else { 240 | this.open(); 241 | } 242 | }, 243 | 244 | /** 245 | * Open the editor UI 246 | */ 247 | open: function (focusEl) { 248 | this.opened = true; 249 | Events.emit('inspectortoggle', true); 250 | 251 | if (this.sceneEl.hasAttribute('embedded')) { 252 | // Remove embedded styles, but keep track of it. 253 | this.sceneEl.removeAttribute('embedded'); 254 | this.sceneEl.setAttribute('aframe-inspector-removed-embedded'); 255 | } 256 | 257 | document.body.classList.add('aframe-inspector-opened'); 258 | this.sceneEl.resize(); 259 | this.sceneEl.pause(); 260 | this.sceneEl.exitVR(); 261 | 262 | Shortcuts.enable(); 263 | 264 | // Trick scene to run the cursor tick. 265 | this.sceneEl.isPlaying = true; 266 | this.cursor.play(); 267 | 268 | if ( 269 | !focusEl && 270 | this.isFirstOpen && 271 | AFRAME.utils.getUrlParameter('inspector') 272 | ) { 273 | // Focus entity with URL parameter on first open. 274 | focusEl = document.getElementById( 275 | AFRAME.utils.getUrlParameter('inspector') 276 | ); 277 | } 278 | if (focusEl) { 279 | this.selectEntity(focusEl); 280 | Events.emit('objectfocus', focusEl.object3D); 281 | } 282 | this.isFirstOpen = false; 283 | }, 284 | 285 | /** 286 | * Closes the editor and gives the control back to the scene 287 | * @return {[type]} [description] 288 | */ 289 | close: function () { 290 | this.opened = false; 291 | Events.emit('inspectortoggle', false); 292 | 293 | // Untrick scene when we enabled this to run the cursor tick. 294 | this.sceneEl.isPlaying = false; 295 | 296 | this.sceneEl.play(); 297 | this.cursor.pause(); 298 | 299 | if (this.sceneEl.hasAttribute('aframe-inspector-removed-embedded')) { 300 | this.sceneEl.setAttribute('embedded', ''); 301 | this.sceneEl.removeAttribute('aframe-inspector-removed-embedded'); 302 | } 303 | document.body.classList.remove('aframe-inspector-opened'); 304 | this.sceneEl.resize(); 305 | Shortcuts.disable(); 306 | } 307 | }; 308 | 309 | AFRAME.INSPECTOR = new Inspector(window.AFRAME_INSPECTOR_CONFIG); 310 | -------------------------------------------------------------------------------- /src/lib/EditorControls.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author qiao / https://github.com/qiao 3 | * @author mrdoob / http://mrdoob.com 4 | * @author alteredq / http://alteredqualia.com/ 5 | * @author WestLangley / http://github.com/WestLangley 6 | */ 7 | 8 | THREE.EditorControls = function (_object, domElement) { 9 | domElement = domElement !== undefined ? domElement : document; 10 | 11 | // API 12 | 13 | this.enabled = true; 14 | this.center = new THREE.Vector3(); 15 | this.panSpeed = 0.001; 16 | this.zoomSpeed = 0.1; 17 | this.rotationSpeed = 0.005; 18 | 19 | var object = _object; 20 | 21 | // internals 22 | 23 | var scope = this; 24 | var vector = new THREE.Vector3(); 25 | var delta = new THREE.Vector3(); 26 | var box = new THREE.Box3(); 27 | 28 | var STATE = { NONE: -1, ROTATE: 0, ZOOM: 1, PAN: 2 }; 29 | var state = STATE.NONE; 30 | 31 | var center = this.center; 32 | var normalMatrix = new THREE.Matrix3(); 33 | var pointer = new THREE.Vector2(); 34 | var pointerOld = new THREE.Vector2(); 35 | var spherical = new THREE.Spherical(); 36 | var sphere = new THREE.Sphere(); 37 | 38 | this.isOrthographic = false; 39 | this.rotationEnabled = true; 40 | this.setCamera = function (_object) { 41 | object = _object; 42 | if (object.type === 'OrthographicCamera') { 43 | this.rotationEnabled = false; 44 | this.isOrthographic = true; 45 | } else { 46 | this.rotationEnabled = true; 47 | this.isOrthographic = false; 48 | } 49 | }; 50 | this.setCamera(_object); 51 | 52 | // events 53 | 54 | var changeEvent = { type: 'change' }; 55 | 56 | this.focus = function (target) { 57 | if (this.isOrthographic) { 58 | return; 59 | } 60 | var distance; 61 | 62 | box.setFromObject(target); 63 | 64 | if (box.isEmpty() === false && !isNaN(box.min.x)) { 65 | box.getCenter(center); 66 | distance = box.getBoundingSphere(sphere).radius; 67 | } else { 68 | // Focusing on an Group, AmbientLight, etc 69 | 70 | center.setFromMatrixPosition(target.matrixWorld); 71 | distance = 0.1; 72 | } 73 | 74 | object.position.copy( 75 | target.localToWorld( 76 | new THREE.Vector3(0, center.y + distance * 0.5, distance * 2.5) 77 | ) 78 | ); 79 | const pos = target.getWorldPosition(new THREE.Vector3()); 80 | pos.y = center.y; 81 | object.lookAt(pos); 82 | 83 | scope.dispatchEvent(changeEvent); 84 | }; 85 | 86 | this.pan = function (delta) { 87 | var distance; 88 | if (this.isOrthographic) { 89 | distance = Math.abs(object.right); 90 | } else { 91 | distance = object.position.distanceTo(center); 92 | } 93 | 94 | delta.multiplyScalar(distance * scope.panSpeed); 95 | delta.applyMatrix3(normalMatrix.getNormalMatrix(object.matrix)); 96 | 97 | object.position.add(delta); 98 | center.add(delta); 99 | 100 | scope.dispatchEvent(changeEvent); 101 | }; 102 | 103 | var ratio = 1; 104 | this.setAspectRatio = function (_ratio) { 105 | ratio = _ratio; 106 | }; 107 | 108 | this.zoom = function (delta) { 109 | var distance = object.position.distanceTo(center); 110 | 111 | delta.multiplyScalar(distance * scope.zoomSpeed); 112 | 113 | if (delta.length() > distance) return; 114 | 115 | delta.applyMatrix3(normalMatrix.getNormalMatrix(object.matrix)); 116 | 117 | if (this.isOrthographic) { 118 | // Change FOV for ortho. 119 | let factor = 1; 120 | if (delta.x + delta.y + delta.z < 0) { 121 | factor = -1; 122 | } 123 | delta = distance * scope.zoomSpeed * factor; 124 | object.left -= delta * ratio; 125 | object.bottom -= delta; 126 | object.right += delta * ratio; 127 | object.top += delta; 128 | if (object.left >= -0.0001) { 129 | return; 130 | } 131 | object.updateProjectionMatrix(); 132 | } else { 133 | object.position.add(delta); 134 | } 135 | 136 | scope.dispatchEvent(changeEvent); 137 | }; 138 | 139 | this.rotate = function (delta) { 140 | if (!this.rotationEnabled) { 141 | return; 142 | } 143 | 144 | vector.copy(object.position).sub(center); 145 | 146 | spherical.setFromVector3(vector); 147 | 148 | spherical.theta += delta.x; 149 | spherical.phi += delta.y; 150 | 151 | spherical.makeSafe(); 152 | 153 | vector.setFromSpherical(spherical); 154 | 155 | object.position.copy(center).add(vector); 156 | 157 | object.lookAt(center); 158 | 159 | scope.dispatchEvent(changeEvent); 160 | }; 161 | 162 | // mouse 163 | 164 | function onMouseDown(event) { 165 | if (scope.enabled === false) return; 166 | 167 | if (event.button === 0) { 168 | state = STATE.ROTATE; 169 | } else if (event.button === 1) { 170 | state = STATE.ZOOM; 171 | } else if (event.button === 2) { 172 | state = STATE.PAN; 173 | } 174 | 175 | pointerOld.set(event.clientX, event.clientY); 176 | 177 | domElement.addEventListener('mousemove', onMouseMove, false); 178 | domElement.addEventListener('mouseup', onMouseUp, false); 179 | domElement.addEventListener('mouseout', onMouseUp, false); 180 | domElement.addEventListener('dblclick', onMouseUp, false); 181 | } 182 | 183 | function onMouseMove(event) { 184 | if (scope.enabled === false) return; 185 | 186 | pointer.set(event.clientX, event.clientY); 187 | 188 | var movementX = pointer.x - pointerOld.x; 189 | var movementY = pointer.y - pointerOld.y; 190 | 191 | if (state === STATE.ROTATE) { 192 | scope.rotate( 193 | delta.set( 194 | -movementX * scope.rotationSpeed, 195 | -movementY * scope.rotationSpeed, 196 | 0 197 | ) 198 | ); 199 | } else if (state === STATE.ZOOM) { 200 | scope.zoom(delta.set(0, 0, movementY)); 201 | } else if (state === STATE.PAN) { 202 | scope.pan(delta.set(-movementX, movementY, 0)); 203 | } 204 | 205 | pointerOld.set(event.clientX, event.clientY); 206 | } 207 | 208 | function onMouseUp(event) { 209 | domElement.removeEventListener('mousemove', onMouseMove, false); 210 | domElement.removeEventListener('mouseup', onMouseUp, false); 211 | domElement.removeEventListener('mouseout', onMouseUp, false); 212 | domElement.removeEventListener('dblclick', onMouseUp, false); 213 | 214 | state = STATE.NONE; 215 | } 216 | 217 | function onMouseWheel(event) { 218 | event.preventDefault(); 219 | 220 | // Normalize deltaY due to https://bugzilla.mozilla.org/show_bug.cgi?id=1392460 221 | scope.zoom(delta.set(0, 0, event.deltaY > 0 ? 1 : -1)); 222 | } 223 | 224 | function contextmenu(event) { 225 | event.preventDefault(); 226 | } 227 | 228 | this.dispose = function () { 229 | domElement.removeEventListener('contextmenu', contextmenu, false); 230 | domElement.removeEventListener('mousedown', onMouseDown, false); 231 | domElement.removeEventListener('wheel', onMouseWheel, false); 232 | 233 | domElement.removeEventListener('mousemove', onMouseMove, false); 234 | domElement.removeEventListener('mouseup', onMouseUp, false); 235 | domElement.removeEventListener('mouseout', onMouseUp, false); 236 | domElement.removeEventListener('dblclick', onMouseUp, false); 237 | 238 | domElement.removeEventListener('touchstart', touchStart, false); 239 | domElement.removeEventListener('touchmove', touchMove, false); 240 | }; 241 | 242 | domElement.addEventListener('contextmenu', contextmenu, false); 243 | domElement.addEventListener('mousedown', onMouseDown, false); 244 | domElement.addEventListener('wheel', onMouseWheel, false); 245 | 246 | // touch 247 | 248 | var touches = [new THREE.Vector3(), new THREE.Vector3(), new THREE.Vector3()]; 249 | var prevTouches = [ 250 | new THREE.Vector3(), 251 | new THREE.Vector3(), 252 | new THREE.Vector3() 253 | ]; 254 | 255 | var prevDistance = null; 256 | 257 | function touchStart(event) { 258 | if (scope.enabled === false) return; 259 | 260 | switch (event.touches.length) { 261 | case 1: 262 | touches[0].set(event.touches[0].pageX, event.touches[0].pageY, 0); 263 | touches[1].set(event.touches[0].pageX, event.touches[0].pageY, 0); 264 | break; 265 | 266 | case 2: 267 | touches[0].set(event.touches[0].pageX, event.touches[0].pageY, 0); 268 | touches[1].set(event.touches[1].pageX, event.touches[1].pageY, 0); 269 | prevDistance = touches[0].distanceTo(touches[1]); 270 | break; 271 | } 272 | 273 | prevTouches[0].copy(touches[0]); 274 | prevTouches[1].copy(touches[1]); 275 | } 276 | 277 | function touchMove(event) { 278 | if (scope.enabled === false) return; 279 | 280 | event.preventDefault(); 281 | event.stopPropagation(); 282 | 283 | function getClosest(touch, touches) { 284 | var closest = touches[0]; 285 | 286 | for (var i in touches) { 287 | if (closest.distanceTo(touch) > touches[i].distanceTo(touch)) { 288 | closest = touches[i]; 289 | } 290 | } 291 | 292 | return closest; 293 | } 294 | 295 | switch (event.touches.length) { 296 | case 1: 297 | touches[0].set(event.touches[0].pageX, event.touches[0].pageY, 0); 298 | touches[1].set(event.touches[0].pageX, event.touches[0].pageY, 0); 299 | scope.rotate( 300 | touches[0] 301 | .sub(getClosest(touches[0], prevTouches)) 302 | .multiplyScalar(-scope.rotationSpeed) 303 | ); 304 | break; 305 | 306 | case 2: 307 | touches[0].set(event.touches[0].pageX, event.touches[0].pageY, 0); 308 | touches[1].set(event.touches[1].pageX, event.touches[1].pageY, 0); 309 | var distance = touches[0].distanceTo(touches[1]); 310 | scope.zoom(delta.set(0, 0, prevDistance - distance)); 311 | prevDistance = distance; 312 | 313 | var offset0 = touches[0] 314 | .clone() 315 | .sub(getClosest(touches[0], prevTouches)); 316 | var offset1 = touches[1] 317 | .clone() 318 | .sub(getClosest(touches[1], prevTouches)); 319 | offset0.x = -offset0.x; 320 | offset1.x = -offset1.x; 321 | 322 | scope.pan(offset0.add(offset1).multiplyScalar(0.5)); 323 | 324 | break; 325 | } 326 | 327 | prevTouches[0].copy(touches[0]); 328 | prevTouches[1].copy(touches[1]); 329 | } 330 | 331 | domElement.addEventListener('touchstart', touchStart, false); 332 | domElement.addEventListener('touchmove', touchMove, false); 333 | }; 334 | 335 | THREE.EditorControls.prototype = Object.create(THREE.EventDispatcher.prototype); 336 | THREE.EditorControls.prototype.constructor = THREE.EditorControls; 337 | -------------------------------------------------------------------------------- /src/lib/Events.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | const Events = new EventEmitter(); 4 | Events.setMaxListeners(0); 5 | 6 | export default Events; 7 | -------------------------------------------------------------------------------- /src/lib/assetsLoader.js: -------------------------------------------------------------------------------- 1 | import Events from './Events'; 2 | 3 | const assetsBaseUrl = 4 | window.AFRAME_SAMPLE_ASSETS_ROOT || 'https://aframe.io/sample-assets/'; 5 | const assetsRelativeUrl = { images: 'dist/images.json' }; 6 | 7 | /** 8 | * Asynchronously load and register components from the registry. 9 | */ 10 | export function AssetsLoader() { 11 | this.images = []; 12 | this.hasLoaded = false; 13 | } 14 | 15 | AssetsLoader.prototype = { 16 | /** 17 | * XHR the assets JSON. 18 | */ 19 | load: function () { 20 | var xhr = new XMLHttpRequest(); 21 | var url = assetsBaseUrl + assetsRelativeUrl.images; 22 | 23 | // @todo Remove the sync call and use a callback 24 | xhr.open('GET', url); 25 | 26 | xhr.onload = () => { 27 | var data = JSON.parse(xhr.responseText); 28 | this.images = data.images; 29 | this.images.forEach((image) => { 30 | image.fullPath = assetsBaseUrl + data.basepath.images + image.path; 31 | image.fullThumbPath = 32 | assetsBaseUrl + data.basepath.images_thumbnails + image.thumbnail; 33 | }); 34 | Events.emit('assetsimagesload', this.images); 35 | }; 36 | xhr.onerror = () => { 37 | console.error('Error loading registry file.'); 38 | }; 39 | xhr.send(); 40 | 41 | this.hasLoaded = true; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/lib/assetsUtils.js: -------------------------------------------------------------------------------- 1 | export function insertNewAsset( 2 | type, 3 | id, 4 | src, 5 | anonymousCrossOrigin, 6 | onLoadedCallback 7 | ) { 8 | var element = null; 9 | switch (type) { 10 | case 'img': 11 | { 12 | element = document.createElement('img'); 13 | element.id = id; 14 | element.src = src; 15 | if (anonymousCrossOrigin) { 16 | element.crossOrigin = 'anonymous'; 17 | } 18 | } 19 | break; 20 | } 21 | 22 | if (element) { 23 | element.onload = function () { 24 | if (onLoadedCallback) { 25 | onLoadedCallback(); 26 | } 27 | }; 28 | document.getElementsByTagName('a-assets')[0].appendChild(element); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/cameras.js: -------------------------------------------------------------------------------- 1 | import Events from './Events'; 2 | 3 | // Save ortho camera FOV / position before switching to restore later. 4 | let currentOrthoDir = ''; 5 | const orthoCameraMemory = { 6 | left: { position: new THREE.Vector3(-10, 0, 0), rotation: new THREE.Euler() }, 7 | right: { position: new THREE.Vector3(10, 0, 0), rotation: new THREE.Euler() }, 8 | top: { position: new THREE.Vector3(0, 10, 0), rotation: new THREE.Euler() }, 9 | bottom: { 10 | position: new THREE.Vector3(0, -10, 0), 11 | rotation: new THREE.Euler() 12 | }, 13 | back: { position: new THREE.Vector3(0, 0, -10), rotation: new THREE.Euler() }, 14 | front: { position: new THREE.Vector3(0, 0, 10), rotation: new THREE.Euler() } 15 | }; 16 | 17 | /** 18 | * Initialize various cameras, store original one. 19 | */ 20 | export function initCameras(inspector) { 21 | const sceneEl = inspector.sceneEl; 22 | 23 | const originalCamera = (inspector.currentCameraEl = sceneEl.camera.el); 24 | 25 | // If the current camera is the default, we should prevent AFRAME from 26 | // remove it once when we inject the editor's camera. 27 | if (inspector.currentCameraEl.hasAttribute('data-aframe-default-camera')) { 28 | inspector.currentCameraEl.removeAttribute('data-aframe-default-camera'); 29 | inspector.currentCameraEl.setAttribute( 30 | 'data-aframe-inspector', 31 | 'default-camera' 32 | ); 33 | } 34 | 35 | inspector.currentCameraEl.setAttribute('camera', 'active', false); 36 | 37 | // Create Inspector camera. 38 | const perspectiveCamera = (inspector.camera = new THREE.PerspectiveCamera()); 39 | perspectiveCamera.far = 10000; 40 | perspectiveCamera.near = 0.01; 41 | perspectiveCamera.position.set(0, 1.6, 2); 42 | const center = new THREE.Vector3(0, 1.6, 0); // same as in viewport.js 43 | perspectiveCamera.lookAt(center); 44 | perspectiveCamera.updateMatrixWorld(); 45 | sceneEl.object3D.add(perspectiveCamera); 46 | sceneEl.camera = perspectiveCamera; 47 | 48 | const ratio = sceneEl.canvas.width / sceneEl.canvas.height; 49 | const orthoCamera = new THREE.OrthographicCamera( 50 | -10 * ratio, 51 | 10 * ratio, 52 | 10, 53 | -10 54 | ); 55 | sceneEl.object3D.add(orthoCamera); 56 | 57 | const cameras = (inspector.cameras = { 58 | perspective: perspectiveCamera, 59 | original: originalCamera, 60 | ortho: orthoCamera 61 | }); 62 | 63 | // Command to switch to perspective. 64 | Events.on('cameraperspectivetoggle', () => { 65 | saveOrthoCamera(inspector.camera, currentOrthoDir); 66 | sceneEl.camera = inspector.camera = cameras.perspective; 67 | Events.emit('cameratoggle', { 68 | camera: inspector.camera, 69 | value: 'perspective' 70 | }); 71 | }); 72 | 73 | // Command to switch to ortographic. 74 | Events.on('cameraorthographictoggle', (dir) => { 75 | saveOrthoCamera(inspector.camera, currentOrthoDir); 76 | sceneEl.camera = inspector.camera = cameras.ortho; 77 | currentOrthoDir = dir; 78 | setOrthoCamera(cameras.ortho, dir, ratio); 79 | 80 | // Set initial rotation for the respective orthographic camera. 81 | if ( 82 | cameras.ortho.rotation.x === 0 && 83 | cameras.ortho.rotation.y === 0 && 84 | cameras.ortho.rotation.z === 0 85 | ) { 86 | cameras.ortho.lookAt(0, 0, 0); 87 | } 88 | Events.emit('cameratoggle', { 89 | camera: inspector.camera, 90 | value: `ortho${dir}` 91 | }); 92 | }); 93 | 94 | return inspector.cameras; 95 | } 96 | 97 | function saveOrthoCamera(camera, dir) { 98 | if (camera.type !== 'OrthographicCamera') { 99 | return; 100 | } 101 | const info = orthoCameraMemory[dir]; 102 | info.position.copy(camera.position); 103 | info.rotation.copy(camera.rotation); 104 | info.left = camera.left; 105 | info.right = camera.right; 106 | info.top = camera.top; 107 | info.bottom = camera.bottom; 108 | } 109 | 110 | function setOrthoCamera(camera, dir, ratio) { 111 | const info = orthoCameraMemory[dir]; 112 | camera.left = info.left || -10 * ratio; 113 | camera.right = info.right || 10 * ratio; 114 | camera.top = info.top || 10; 115 | camera.bottom = info.bottom || -10; 116 | camera.position.copy(info.position); 117 | camera.rotation.copy(info.rotation); 118 | } 119 | 120 | /** 121 | * Copy position and rotation from source aframe camera to target camera. 122 | * Also set center for EditorControls 2m in front of camera. 123 | * 124 | * @param {Object3D} sourceCamera 125 | * @param {Camera} targetCamera 126 | * @param {EditorControls} controls 127 | */ 128 | export function copyCameraPosition(sourceCamera, targetCamera, controls) { 129 | sourceCamera.getWorldPosition(targetCamera.position); 130 | sourceCamera.getWorldQuaternion(targetCamera.quaternion); 131 | targetCamera.updateMatrixWorld(); 132 | 133 | // Set center for EditorControls 2m in front of camera. 134 | const worldDirection = new THREE.Vector3(); 135 | targetCamera.getWorldDirection(worldDirection); 136 | const center = targetCamera.position 137 | .clone() 138 | .addScaledVector(worldDirection, 2); 139 | controls.center.copy(center); 140 | } 141 | -------------------------------------------------------------------------------- /src/lib/config.js: -------------------------------------------------------------------------------- 1 | export function Config(overrides) { 2 | return { 3 | // Keep the inspector perspective camera position in sync with the A-Frame active camera. 4 | copyCameraPosition: true, 5 | ...overrides 6 | }; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/history.js: -------------------------------------------------------------------------------- 1 | import Events from './Events'; 2 | 3 | export const updates = {}; 4 | 5 | /** 6 | * Store change to export. 7 | * 8 | * payload: entity, component, property, value. 9 | */ 10 | Events.on('entityupdate', (payload) => { 11 | let value = payload.value; 12 | 13 | const entity = payload.entity; 14 | updates[entity.id] = updates[entity.id] || {}; 15 | 16 | const component = AFRAME.components[payload.component]; 17 | if (component) { 18 | if (payload.property) { 19 | updates[entity.id][payload.component] = 20 | updates[entity.id][payload.component] || {}; 21 | if (component.schema[payload.property]) { 22 | value = component.schema[payload.property].stringify(payload.value); 23 | } 24 | updates[entity.id][payload.component][payload.property] = value; 25 | } else { 26 | value = component.schema.stringify(payload.value); 27 | updates[entity.id][payload.component] = value; 28 | } 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /src/lib/raycaster.js: -------------------------------------------------------------------------------- 1 | import Events from './Events'; 2 | 3 | export function initRaycaster(inspector) { 4 | // Use cursor="rayOrigin: mouse". 5 | const mouseCursor = document.createElement('a-entity'); 6 | mouseCursor.setAttribute('id', 'aframeInspectorMouseCursor'); 7 | mouseCursor.setAttribute('raycaster', { 8 | interval: 100, 9 | objects: 'a-scene :not([data-aframe-inspector])' 10 | }); 11 | mouseCursor.setAttribute('cursor', 'rayOrigin', 'mouse'); 12 | mouseCursor.setAttribute('data-aframe-inspector', 'true'); 13 | 14 | // Only visible objects. 15 | const raycaster = mouseCursor.components.raycaster; 16 | const refreshObjects = raycaster.refreshObjects; 17 | const overrideRefresh = () => { 18 | refreshObjects.call(raycaster); 19 | const objects = raycaster.objects; 20 | raycaster.objects = objects.filter((node) => { 21 | while (node) { 22 | if (!node.visible) { 23 | return false; 24 | } 25 | node = node.parent; 26 | } 27 | return true; 28 | }); 29 | }; 30 | raycaster.refreshObjects = overrideRefresh; 31 | 32 | inspector.sceneEl.appendChild(mouseCursor); 33 | inspector.cursor = mouseCursor; 34 | 35 | mouseCursor.addEventListener('click', handleClick); 36 | mouseCursor.addEventListener('mouseenter', onMouseEnter); 37 | mouseCursor.addEventListener('mouseleave', onMouseLeave); 38 | inspector.container.addEventListener('mousedown', onMouseDown); 39 | inspector.container.addEventListener('mouseup', onMouseUp); 40 | inspector.container.addEventListener('dblclick', onDoubleClick); 41 | 42 | inspector.sceneEl.canvas.addEventListener('mouseleave', () => { 43 | setTimeout(() => { 44 | Events.emit('raycastermouseleave', null); 45 | }); 46 | }); 47 | 48 | const onDownPosition = new THREE.Vector2(); 49 | const onUpPosition = new THREE.Vector2(); 50 | 51 | function onMouseEnter() { 52 | Events.emit( 53 | 'raycastermouseenter', 54 | mouseCursor.components.cursor.intersectedEl 55 | ); 56 | } 57 | 58 | function onMouseLeave() { 59 | Events.emit( 60 | 'raycastermouseleave', 61 | mouseCursor.components.cursor.intersectedEl 62 | ); 63 | } 64 | 65 | function handleClick(evt) { 66 | // Check to make sure not dragging. 67 | if (onDownPosition.distanceTo(onUpPosition) === 0) { 68 | inspector.selectEntity(evt.detail.intersectedEl); 69 | } 70 | } 71 | 72 | function onMouseDown(event) { 73 | if (event instanceof CustomEvent) { 74 | return; 75 | } 76 | event.preventDefault(); 77 | const array = getMousePosition( 78 | inspector.container, 79 | event.clientX, 80 | event.clientY 81 | ); 82 | onDownPosition.fromArray(array); 83 | } 84 | 85 | function onMouseUp(event) { 86 | if (event instanceof CustomEvent) { 87 | return; 88 | } 89 | event.preventDefault(); 90 | const array = getMousePosition( 91 | inspector.container, 92 | event.clientX, 93 | event.clientY 94 | ); 95 | onUpPosition.fromArray(array); 96 | } 97 | 98 | /** 99 | * Focus on double click. 100 | */ 101 | function onDoubleClick(event) { 102 | const intersectedEl = mouseCursor.components.cursor.intersectedEl; 103 | if (!intersectedEl) { 104 | return; 105 | } 106 | Events.emit('objectfocus', intersectedEl.object3D); 107 | } 108 | 109 | return { 110 | el: mouseCursor, 111 | enable: () => { 112 | mouseCursor.setAttribute('raycaster', 'enabled', true); 113 | inspector.container.addEventListener('mousedown', onMouseDown); 114 | inspector.container.addEventListener('mouseup', onMouseUp); 115 | inspector.container.addEventListener('dblclick', onDoubleClick); 116 | }, 117 | disable: () => { 118 | mouseCursor.setAttribute('raycaster', 'enabled', false); 119 | inspector.container.removeEventListener('mousedown', onMouseDown); 120 | inspector.container.removeEventListener('mouseup', onMouseUp); 121 | inspector.container.removeEventListener('dblclick', onDoubleClick); 122 | } 123 | }; 124 | } 125 | 126 | function getMousePosition(dom, x, y) { 127 | const rect = dom.getBoundingClientRect(); 128 | return [(x - rect.left) / rect.width, (y - rect.top) / rect.height]; 129 | } 130 | -------------------------------------------------------------------------------- /src/lib/shortcuts.js: -------------------------------------------------------------------------------- 1 | import Events from './Events'; 2 | import { 3 | removeSelectedEntity, 4 | cloneSelectedEntity, 5 | cloneEntity 6 | } from './entity'; 7 | import { getOS } from './utils'; 8 | 9 | const os = getOS(); 10 | 11 | function shouldCaptureKeyEvent(event) { 12 | return ( 13 | event.target.closest('#cameraToolbar') || 14 | (event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') 15 | ); 16 | } 17 | 18 | export const Shortcuts = { 19 | enabled: false, 20 | shortcuts: { 21 | default: {}, 22 | modules: {} 23 | }, 24 | onKeyUp: function (event) { 25 | if (!shouldCaptureKeyEvent(event) || !AFRAME.INSPECTOR.opened) { 26 | return; 27 | } 28 | 29 | var keyCode = event.keyCode; 30 | 31 | // h: help 32 | if (keyCode === 72) { 33 | Events.emit('openhelpmodal'); 34 | } 35 | 36 | // esc: unselect entity 37 | if (keyCode === 27) { 38 | if (this.inspector.selectedEntity) { 39 | this.inspector.selectEntity(null); 40 | } 41 | } 42 | 43 | // w: translate 44 | if (keyCode === 87) { 45 | Events.emit('transformmodechange', 'translate'); 46 | } 47 | 48 | // e: rotate 49 | if (keyCode === 69) { 50 | Events.emit('transformmodechange', 'rotate'); 51 | } 52 | 53 | // r: scale 54 | if (keyCode === 82) { 55 | Events.emit('transformmodechange', 'scale'); 56 | } 57 | 58 | // o: transform space 59 | if (keyCode === 79) { 60 | Events.emit('transformspacechange'); 61 | } 62 | 63 | // g: toggle grid 64 | if (keyCode === 71) { 65 | Events.emit('togglegrid'); 66 | } 67 | 68 | // n: new entity 69 | if (keyCode === 78) { 70 | Events.emit('entitycreate', { element: 'a-entity', components: {} }); 71 | } 72 | 73 | // backspace & delete: remove selected entity 74 | if (keyCode === 8 || keyCode === 46) { 75 | removeSelectedEntity(); 76 | } 77 | 78 | // d: clone selected entity 79 | if (keyCode === 68) { 80 | cloneSelectedEntity(); 81 | } 82 | 83 | // f: Focus on selected entity. 84 | if (keyCode === 70) { 85 | const selectedEntity = AFRAME.INSPECTOR.selectedEntity; 86 | if (selectedEntity !== undefined && selectedEntity !== null) { 87 | Events.emit('objectfocus', selectedEntity.object3D); 88 | } 89 | } 90 | 91 | if (keyCode === 49) { 92 | Events.emit('cameraperspectivetoggle'); 93 | } else if (keyCode === 50) { 94 | Events.emit('cameraorthographictoggle', 'left'); 95 | } else if (keyCode === 51) { 96 | Events.emit('cameraorthographictoggle', 'right'); 97 | } else if (keyCode === 52) { 98 | Events.emit('cameraorthographictoggle', 'top'); 99 | } else if (keyCode === 53) { 100 | Events.emit('cameraorthographictoggle', 'bottom'); 101 | } else if (keyCode === 54) { 102 | Events.emit('cameraorthographictoggle', 'back'); 103 | } else if (keyCode === 55) { 104 | Events.emit('cameraorthographictoggle', 'front'); 105 | } 106 | 107 | for (var moduleName in this.shortcuts.modules) { 108 | var shortcutsModule = this.shortcuts.modules[moduleName]; 109 | if ( 110 | shortcutsModule[keyCode] && 111 | (!shortcutsModule[keyCode].mustBeActive || 112 | (shortcutsModule[keyCode].mustBeActive && 113 | AFRAME.INSPECTOR.modules[moduleName].active)) 114 | ) { 115 | this.shortcuts.modules[moduleName][keyCode].callback(); 116 | } 117 | } 118 | }, 119 | onKeyDown: function (event) { 120 | if (!shouldCaptureKeyEvent(event) || !AFRAME.INSPECTOR.opened) { 121 | return; 122 | } 123 | 124 | if ( 125 | (event.ctrlKey && os !== 'macos') || 126 | (event.metaKey && os === 'macos') 127 | ) { 128 | if ( 129 | AFRAME.INSPECTOR.selectedEntity && 130 | document.activeElement.tagName !== 'INPUT' 131 | ) { 132 | // c: copy selected entity 133 | if (event.keyCode === 67) { 134 | AFRAME.INSPECTOR.entityToCopy = AFRAME.INSPECTOR.selectedEntity; 135 | } 136 | 137 | // v: paste copied entity 138 | if (event.keyCode === 86) { 139 | cloneEntity(AFRAME.INSPECTOR.entityToCopy); 140 | } 141 | } 142 | 143 | // s: focus search input 144 | if (event.keyCode === 83) { 145 | event.preventDefault(); 146 | event.stopPropagation(); 147 | document.getElementById('filter').focus(); 148 | } 149 | } 150 | 151 | // 0: toggle sidebars visibility 152 | if (event.keyCode === 48) { 153 | Events.emit('togglesidebar', { which: 'all' }); 154 | event.preventDefault(); 155 | event.stopPropagation(); 156 | } 157 | }, 158 | enable: function () { 159 | if (this.enabled) { 160 | this.disable(); 161 | } 162 | 163 | window.addEventListener('keydown', this.onKeyDown, false); 164 | window.addEventListener('keyup', this.onKeyUp, false); 165 | this.enabled = true; 166 | }, 167 | disable: function () { 168 | window.removeEventListener('keydown', this.onKeyDown); 169 | window.removeEventListener('keyup', this.onKeyUp); 170 | this.enabled = false; 171 | }, 172 | checkModuleShortcutCollision: function (keyCode, moduleName, mustBeActive) { 173 | if ( 174 | this.shortcuts.modules[moduleName] && 175 | this.shortcuts.modules[moduleName][keyCode] 176 | ) { 177 | console.warn( 178 | 'Keycode <%s> already registered as shortcut within the same module', 179 | keyCode 180 | ); 181 | } 182 | }, 183 | registerModuleShortcut: function ( 184 | keyCode, 185 | callback, 186 | moduleName, 187 | mustBeActive 188 | ) { 189 | if (this.checkModuleShortcutCollision(keyCode, moduleName, mustBeActive)) { 190 | return; 191 | } 192 | 193 | if (!this.shortcuts.modules[moduleName]) { 194 | this.shortcuts.modules[moduleName] = {}; 195 | } 196 | 197 | if (mustBeActive !== false) { 198 | mustBeActive = true; 199 | } 200 | 201 | this.shortcuts.modules[moduleName][keyCode] = { 202 | callback, 203 | mustBeActive 204 | }; 205 | }, 206 | init: function (inspector) { 207 | this.inspector = inspector; 208 | this.onKeyDown = this.onKeyDown.bind(this); 209 | this.onKeyUp = this.onKeyUp.bind(this); 210 | } 211 | }; 212 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export function equal(var1, var2) { 2 | var keys1; 3 | var keys2; 4 | var type1 = typeof var1; 5 | var type2 = typeof var2; 6 | if (type1 !== type2) { 7 | return false; 8 | } 9 | if (type1 !== 'object' || var1 === null || var2 === null) { 10 | return var1 === var2; 11 | } 12 | if (var1 instanceof HTMLElement || var2 instanceof HTMLElement) { 13 | // If we're here, we're comparing a value of a schema property of type selector like movement-controls's camera property 14 | return var1 === var2; 15 | } 16 | keys1 = Object.keys(var1); 17 | keys2 = Object.keys(var2); 18 | if (keys1.length !== keys2.length) { 19 | return false; 20 | } 21 | for (var i = 0; i < keys1.length; i++) { 22 | if (!equal(var1[keys1[i]], var2[keys2[i]])) { 23 | return false; 24 | } 25 | } 26 | return true; 27 | } 28 | 29 | export function getOS() { 30 | var userAgent = window.navigator.userAgent; 31 | var platform = window.navigator.platform; 32 | var macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']; 33 | var windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']; 34 | var iosPlatforms = ['iPhone', 'iPad', 'iPod']; 35 | var os = null; 36 | 37 | if (macosPlatforms.indexOf(platform) !== -1) { 38 | os = 'macos'; 39 | } else if (iosPlatforms.indexOf(platform) !== -1) { 40 | os = 'ios'; 41 | } else if (windowsPlatforms.indexOf(platform) !== -1) { 42 | os = 'windows'; 43 | } else if (/Android/.test(userAgent)) { 44 | os = 'android'; 45 | } else if (!os && /Linux/.test(platform)) { 46 | os = 'linux'; 47 | } 48 | 49 | return os; 50 | } 51 | 52 | export function injectCSS(url) { 53 | var link = document.createElement('link'); 54 | link.href = url; 55 | link.type = 'text/css'; 56 | link.rel = 'stylesheet'; 57 | link.media = 'screen,print'; 58 | link.setAttribute('data-aframe-inspector', 'style'); 59 | document.head.appendChild(link); 60 | } 61 | 62 | export function injectJS(url, onLoad, onError) { 63 | var link = document.createElement('script'); 64 | link.src = url; 65 | link.charset = 'utf-8'; 66 | link.setAttribute('data-aframe-inspector', 'style'); 67 | 68 | if (onLoad) { 69 | link.addEventListener('load', onLoad); 70 | } 71 | 72 | if (onError) { 73 | link.addEventListener('error', onError); 74 | } 75 | 76 | document.head.appendChild(link); 77 | } 78 | 79 | export function saveString(text, filename, mimeType) { 80 | saveBlob(new Blob([text], { type: mimeType }), filename); 81 | } 82 | 83 | export function saveBlob(blob, filename) { 84 | var link = document.createElement('a'); 85 | link.style.display = 'none'; 86 | document.body.appendChild(link); 87 | const url = URL.createObjectURL(blob); 88 | link.href = url; 89 | link.download = filename || 'ascene.html'; 90 | link.click(); 91 | URL.revokeObjectURL(url); 92 | link.remove(); 93 | } 94 | 95 | // Compares 2 vector objects up to size 4 96 | // Expect v1 and v2 to take format {x: number, y: number, z: number, w:number} 97 | // Smaller vectors (ie. vec2) should work as well since their z & w vals will be the same (undefined) 98 | export function areVectorsEqual(v1, v2) { 99 | return ( 100 | Object.is(v1.x, v2.x) && 101 | Object.is(v1.y, v2.y) && 102 | Object.is(v1.z, v2.z) && 103 | Object.is(v1.w, v2.w) 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/lib/viewport.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import TransformControls from './TransformControls.js'; 3 | import EditorControls from './EditorControls.js'; 4 | 5 | import { copyCameraPosition } from './cameras'; 6 | import { initRaycaster } from './raycaster'; 7 | import Events from './Events'; 8 | 9 | /** 10 | * Transform controls stuff mostly. 11 | */ 12 | export function Viewport(inspector) { 13 | // Initialize raycaster and picking in differentpmodule. 14 | const mouseCursor = initRaycaster(inspector); 15 | const sceneEl = inspector.sceneEl; 16 | 17 | sceneEl.addEventListener('camera-set-active', (event) => { 18 | // If we're in edit mode, save the newly active camera and activate when exiting. 19 | if (inspector.opened) { 20 | inspector.cameras.original = event.detail.cameraEl; 21 | } 22 | }); 23 | 24 | // Helpers. 25 | const sceneHelpers = inspector.sceneHelpers; 26 | const grid = new THREE.GridHelper(30, 60, 0xaaaaaa, 0x262626); 27 | sceneHelpers.add(grid); 28 | 29 | const selectionBox = new THREE.BoxHelper(); 30 | selectionBox.material.depthTest = false; 31 | selectionBox.material.transparent = true; 32 | selectionBox.material.color.set(0x1faaf2); 33 | selectionBox.visible = false; 34 | sceneHelpers.add(selectionBox); 35 | 36 | function updateHelpers(object) { 37 | object.traverse((node) => { 38 | if (inspector.helpers[node.uuid] && inspector.helpers[node.uuid].update) { 39 | inspector.helpers[node.uuid].update(); 40 | } 41 | }); 42 | } 43 | 44 | const camera = inspector.camera; 45 | const transformControls = new THREE.TransformControls( 46 | camera, 47 | inspector.container 48 | ); 49 | transformControls.size = 0.75; 50 | transformControls.addEventListener('objectChange', (evt) => { 51 | const object = transformControls.object; 52 | if (object === undefined) { 53 | return; 54 | } 55 | 56 | selectionBox.setFromObject(object); 57 | 58 | updateHelpers(object); 59 | 60 | // Emit update event for watcher. 61 | let component; 62 | let value; 63 | if (evt.mode === 'translate') { 64 | component = 'position'; 65 | value = `${object.position.x} ${object.position.y} ${object.position.z}`; 66 | } else if (evt.mode === 'rotate') { 67 | component = 'rotation'; 68 | const d = THREE.MathUtils.radToDeg; 69 | value = `${d(object.rotation.x)} ${d(object.rotation.y)} ${d( 70 | object.rotation.z 71 | )}`; 72 | } else if (evt.mode === 'scale') { 73 | component = 'scale'; 74 | value = `${object.scale.x} ${object.scale.y} ${object.scale.z}`; 75 | } 76 | 77 | // We need to call setAttribute for component attrValue to be up to date, 78 | // so that entity.flushToDOM() works correctly when duplicating an entity. 79 | transformControls.object.el.setAttribute(component, value); 80 | 81 | Events.emit('entityupdate', { 82 | component: component, 83 | entity: transformControls.object.el, 84 | property: '', 85 | value: value 86 | }); 87 | }); 88 | 89 | transformControls.addEventListener('mouseDown', () => { 90 | controls.enabled = false; 91 | }); 92 | 93 | transformControls.addEventListener('mouseUp', () => { 94 | controls.enabled = true; 95 | }); 96 | 97 | sceneHelpers.add(transformControls); 98 | 99 | Events.on('entityupdate', (detail) => { 100 | const object = detail.entity.object3D; 101 | if ( 102 | inspector.selected === object && 103 | inspector.selectedEntity.object3DMap.mesh 104 | ) { 105 | selectionBox.setFromObject(inspector.selected); 106 | } 107 | }); 108 | 109 | // Controls need to be added *after* main logic. 110 | const controls = new THREE.EditorControls(camera, inspector.container); 111 | controls.center.set(0, 1.6, 0); 112 | controls.rotationSpeed = 0.0035; 113 | controls.zoomSpeed = 0.05; 114 | controls.setAspectRatio(sceneEl.canvas.width / sceneEl.canvas.height); 115 | controls.addEventListener('change', () => { 116 | transformControls.update(true); // true is updateScale 117 | Events.emit('camerachanged'); 118 | }); 119 | 120 | Events.on('cameratoggle', (data) => { 121 | controls.setCamera(data.camera); 122 | transformControls.setCamera(data.camera); 123 | updateAspectRatio(); 124 | }); 125 | 126 | function disableControls() { 127 | mouseCursor.disable(); 128 | transformControls.dispose(); 129 | controls.enabled = false; 130 | } 131 | 132 | function enableControls() { 133 | mouseCursor.enable(); 134 | transformControls.activate(); 135 | controls.enabled = true; 136 | } 137 | enableControls(); 138 | 139 | Events.on('inspectorcleared', () => { 140 | controls.center.set(0, 0, 0); 141 | }); 142 | 143 | Events.on('transformmodechange', (mode) => { 144 | transformControls.setMode(mode); 145 | }); 146 | 147 | Events.on('translationsnapchanged', (dist) => { 148 | transformControls.setTranslationSnap(dist); 149 | }); 150 | 151 | Events.on('rotationsnapchanged', (dist) => { 152 | transformControls.setRotationSnap(dist); 153 | }); 154 | 155 | Events.on('transformspacechanged', (space) => { 156 | transformControls.setSpace(space); 157 | }); 158 | 159 | Events.on('objectselect', (object) => { 160 | selectionBox.visible = false; 161 | transformControls.detach(); 162 | if (object && object.el) { 163 | if (object.el.getObject3D('mesh')) { 164 | selectionBox.setFromObject(object); 165 | selectionBox.visible = true; 166 | } else if (object.el.hasAttribute('gltf-model')) { 167 | const listener = (event) => { 168 | if (event.target !== object.el) return; // we got an event for a child, ignore 169 | selectionBox.setFromObject(object); 170 | selectionBox.visible = true; 171 | object.el.removeEventListener('model-loaded', listener); 172 | }; 173 | object.el.addEventListener('model-loaded', listener); 174 | } 175 | 176 | transformControls.attach(object); 177 | } 178 | }); 179 | 180 | Events.on('objectfocus', (object) => { 181 | controls.focus(object); 182 | }); 183 | 184 | Events.on('geometrychanged', (object) => { 185 | if (object !== null) { 186 | selectionBox.setFromObject(object); 187 | } 188 | }); 189 | 190 | Events.on('entityupdate', (detail) => { 191 | const object = detail.entity.object3D; 192 | if (inspector.selected === object) { 193 | // Hack because object3D always has geometry :( 194 | if ( 195 | object.geometry && 196 | ((object.geometry.vertices && object.geometry.vertices.length > 0) || 197 | (object.geometry.attributes && 198 | object.geometry.attributes.position && 199 | object.geometry.attributes.position.array.length)) 200 | ) { 201 | selectionBox.setFromObject(object); 202 | } 203 | } 204 | 205 | transformControls.update(); 206 | if (object instanceof THREE.PerspectiveCamera) { 207 | object.updateProjectionMatrix(); 208 | } 209 | 210 | updateHelpers(object); 211 | }); 212 | 213 | function updateAspectRatio() { 214 | if (!inspector.opened) return; 215 | // Modifying aspect for perspective camera is done by aframe a-scene.resize function 216 | // when the perspective camera is the active camera, so we actually do it a second time here, 217 | // but we need to modify it ourself when we switch from ortho camera to perspective camera (updateAspectRatio() is called in cameratoggle handler). 218 | const camera = inspector.camera; 219 | const aspect = 220 | inspector.container.offsetWidth / inspector.container.offsetHeight; 221 | if (camera.isPerspectiveCamera) { 222 | camera.aspect = aspect; 223 | } else if (camera.isOrthographicCamera) { 224 | const frustumSize = camera.top - camera.bottom; 225 | camera.left = (-frustumSize * aspect) / 2; 226 | camera.right = (frustumSize * aspect) / 2; 227 | camera.top = frustumSize / 2; 228 | camera.bottom = -frustumSize / 2; 229 | } 230 | 231 | controls.setAspectRatio(aspect); // for zoom in/out to work correctly for orthographic camera 232 | camera.updateProjectionMatrix(); 233 | 234 | const cameraHelper = inspector.helpers[camera.uuid]; 235 | if (cameraHelper) cameraHelper.update(); 236 | } 237 | 238 | inspector.sceneEl.addEventListener('rendererresize', updateAspectRatio); 239 | 240 | Events.on('gridvisibilitychanged', (showGrid) => { 241 | grid.visible = showGrid; 242 | }); 243 | 244 | Events.on('togglegrid', () => { 245 | grid.visible = !grid.visible; 246 | }); 247 | 248 | Events.on('inspectortoggle', (active) => { 249 | if (active) { 250 | enableControls(); 251 | AFRAME.scenes[0].camera = inspector.camera; 252 | Array.prototype.slice 253 | .call(document.querySelectorAll('.a-enter-vr,.rs-base')) 254 | .forEach((element) => { 255 | element.style.display = 'none'; 256 | }); 257 | if (inspector.config.copyCameraPosition) { 258 | copyCameraPosition( 259 | inspector.cameras.original.object3D, 260 | inspector.cameras.perspective, 261 | controls 262 | ); 263 | } 264 | } else { 265 | disableControls(); 266 | inspector.cameras.original.setAttribute('camera', 'active', 'true'); 267 | AFRAME.scenes[0].camera = 268 | inspector.cameras.original.getObject3D('camera'); 269 | Array.prototype.slice 270 | .call(document.querySelectorAll('.a-enter-vr,.rs-base')) 271 | .forEach((element) => { 272 | element.style.display = 'block'; 273 | }); 274 | } 275 | }); 276 | } 277 | -------------------------------------------------------------------------------- /src/style/components.styl: -------------------------------------------------------------------------------- 1 | @import './lib'; 2 | 3 | propertyRowDefined() { 4 | .propertyRowDefined { 5 | {block} 6 | } 7 | } 8 | 9 | .components 10 | background-color $bg 11 | color $white 12 | height 100% 13 | overflow auto 14 | position fixed 15 | width 331px 16 | 17 | div.vec2, 18 | div.vec3, 19 | div.vec4 20 | display inline 21 | 22 | .vec2 input.number, 23 | .vec3 input.number 24 | width 40px 25 | 26 | .vec4 input.number 27 | width 34px 28 | 29 | .collapsible-header 30 | align-items center 31 | display flex 32 | justify-content space-between 33 | .entityPrint 34 | color #fff 35 | 36 | .collapsible-content 37 | padding 5px 0 38 | 39 | .componentTitle span 40 | max-width 200px 41 | overflow hidden 42 | text-overflow ellipsis 43 | text-transform uppercase 44 | white-space nowrap 45 | color #fff 46 | font-weight 600 47 | vertical-align bottom !important 48 | 49 | .collapsible .static 50 | background $bglight 51 | border-bottom 2px solid $bg 52 | box-sizing content-box 53 | cursor pointer 54 | height 16px 55 | padding 8px 10px 12px 10px 56 | vertical-align bottom 57 | font-size 13px 58 | &:hover 59 | background $bglighter 60 | /* 61 | .collapsible 62 | &.collapsed 63 | background-color $grayalt 64 | .static, 65 | .componentHeaderActions 66 | color #dedede 67 | &:hover 68 | background-color $grayhover 69 | */ 70 | .collapsible .menu 71 | text-align right 72 | 73 | .collapsible .menuafter 74 | color #bbb 75 | content '\2807' 76 | font-size 12px 77 | padding 5px 78 | text-align right 79 | 80 | .collapsible .static 81 | margin 0 82 | 83 | .collapsible .static .collapse-button 84 | border 6px solid transparent 85 | float left 86 | height 0 87 | margin-right 10px 88 | margin-left 2px 89 | width 0 90 | 91 | .collapsible.collapsed .static .collapse-button 92 | border-left-color $white 93 | margin-top 4px 94 | 95 | .collapsible:not(.collapsed) .static .collapse-button 96 | border-top-color $white 97 | margin-top 7px 98 | 99 | .propertyRow 100 | align-items center 101 | display flex 102 | font-size 13px 103 | min-height 30px 104 | padding 2px 15px 105 | 106 | .text 107 | cursor default 108 | display inline-block 109 | overflow hidden 110 | padding-right 10px 111 | text-overflow ellipsis 112 | vertical-align middle 113 | width 118px 114 | 115 | .map_value 116 | margin 0 0 0 5px 117 | width 68px 118 | 119 | .Select-control 120 | font-size 11px 121 | height 24px 122 | 123 | .Select-placeholder, 124 | .Select--single > .Select-control .Select-value 125 | line-height 19px 126 | 127 | .Select-input 128 | height 22px 129 | 130 | input[type=text], 131 | input[type=number], 132 | input.string, 133 | input.number 134 | background $bgdark 135 | color #1faaf2 136 | min-height 26px 137 | padding-bottom 1px 138 | padding-left 5px 139 | padding-right 5px 140 | padding-top 1px 141 | &:last-child 142 | padding-right 0 143 | 144 | input.string 145 | padding-left 8px 146 | box-sizing border-box 147 | width 165px 148 | 149 | input[type=text]:focus, 150 | input.string:focus 151 | box-shadow none 152 | 153 | .color_value 154 | margin 0 0 0 5px 155 | width 68px 156 | letter-spacing 1px 157 | 158 | .propertyRowDefined .text 159 | color #FAFAFA 160 | font-weight 500 161 | 162 | .components * 163 | vertical-align middle 164 | 165 | span.subcomponent 166 | color #999 167 | float none !important 168 | margin-left 10px 169 | vertical-align top !important 170 | 171 | #addComponentContainer 172 | align-items center 173 | display flex 174 | flex-direction column 175 | justify-content center 176 | padding 20px 10px 177 | background $bgdark 178 | 179 | #addComponent 180 | text-align left 181 | width 200px 182 | .select__control 183 | background #161616 184 | height 35px 185 | color $primary 186 | 187 | #addComponentHeader 188 | font-size 15px 189 | margin 5px 0 10px 0 190 | 191 | input[type=text]:focus 192 | box-shadow none 193 | 194 | .Select-menu-outer .is-focused span 195 | color #fff 196 | 197 | .component-title 198 | align-items center 199 | display flex 200 | 201 | #componentEntityHeader 202 | .collapsible-header 203 | bottom 4px 204 | position relative 205 | .collapse-button 206 | display none 207 | .static 208 | height 13px 209 | .entityPrint 210 | font-size 15px 211 | padding-left 5px 212 | .entityName 213 | max-width 160px 214 | top 0 215 | .entityIcons 216 | color #FAFAFA 217 | 218 | #mixinSelect 219 | width 160px 220 | 221 | .propertyRow .texture 222 | display flex 223 | input 224 | margin-left 0 225 | width 120px 226 | 227 | #componentEntityHeader .gltfIcon img 228 | top 0 -------------------------------------------------------------------------------- /src/style/entity.styl: -------------------------------------------------------------------------------- 1 | @import './lib'; 2 | 3 | .entityPrint 4 | font-family $normalfont 5 | line-height 1.15em 6 | 7 | .entityName 8 | display inline-block 9 | overflow hidden 10 | position relative 11 | text-overflow ellipsis 12 | top 3px 13 | white-space nowrap 14 | 15 | [data-entity-name-type="id"] 16 | color $red 17 | 18 | [data-entity-name-type="class"] 19 | color $green 20 | 21 | [data-entity-name-type="mixin"] 22 | color $orange 23 | -------------------------------------------------------------------------------- /src/style/help.styl: -------------------------------------------------------------------------------- 1 | .help-lists 2 | display flex 3 | justify-content space-around 4 | 5 | .help-list 6 | list-style none 7 | margin 0 8 | padding 0 0 10px 9 | width 350px 10 | 11 | .help-list li 12 | margin-right 40px 13 | 14 | .help-key-unit 15 | line-height 1.8 16 | margin-right 2em 17 | padding 5px 0 18 | 19 | .help-key 20 | bottom 2px 21 | margin-right 4px 22 | min-width 60px 23 | position relative 24 | 25 | .help-key span 26 | background-color #2e2e2e 27 | background-repeat repeat-x 28 | border 1px solid #666 29 | border-radius 3px 30 | box-shadow 0 0 5px #000 31 | color #999 32 | display inline-block 33 | font-size 12px 34 | padding 0 8px 35 | text-align center 36 | 37 | .help-key-def 38 | color #bbb 39 | display inline-block 40 | margin-left 1em 41 | -------------------------------------------------------------------------------- /src/style/index.styl: -------------------------------------------------------------------------------- 1 | @import './lib'; 2 | 3 | body.aframe-inspector-opened, 4 | .toggle-edit, 5 | .sponsor-btn 6 | font-family BlinkMacSystemFont, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif 7 | 8 | .wf-roboto-n4-active body.aframe-inspector-opened, 9 | .wf-roboto-n4-active .toggle-edit, 10 | .wf-roboto-n4-active .sponsor-btn 11 | font-family BlinkMacSystemFont, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif 12 | 13 | body.aframe-inspector-opened 14 | background $bgdark 15 | color #fff 16 | font-size 12px 17 | margin 0 18 | overflow hidden 19 | 20 | #aframeInspector 21 | @import './scenegraph'; 22 | @import './components'; 23 | @import './entity'; 24 | @import './help'; 25 | @import './select'; 26 | @import './textureModal'; 27 | @import './viewport'; 28 | @import './widgets'; 29 | 30 | .Select, 31 | code, 32 | pre, 33 | input, 34 | textarea, 35 | select 36 | font-family $monospace 37 | font-size 13px 38 | 39 | .wf-robotomono-n4-active .Select, 40 | .wf-robotomono-n4-active code, 41 | .wf-robotomono-n4-active pre, 42 | .wf-robotomono-n4-active input, 43 | .wf-robotomono-n4-active textarea, 44 | .wf-robotomono-n4-active select 45 | font-family Roboto Mono, Consolas, Andale Mono, Monaco, Courier New, monospace 46 | 47 | hr 48 | border 0 49 | border-top 1px solid #ccc 50 | 51 | a 52 | cursor pointer 53 | 54 | button 55 | position relative 56 | 57 | code 58 | font-family Consolas, Andale Mono, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace 59 | 60 | textarea 61 | tab-size 4 62 | white-space pre 63 | word-wrap normal 64 | 65 | textarea.success 66 | border-color #8b8 !important 67 | 68 | textarea.fail 69 | background-color rgba(255, 0, 0, 0.05) 70 | border-color #f00 !important 71 | 72 | textarea, 73 | input 74 | outline none /* osx */ 75 | 76 | .gltfIcon img 77 | box-sizing content-box 78 | display inline 79 | height 20px 80 | left 5px 81 | padding 0 5px 82 | position relative 83 | top 4px 84 | vertical-align baseline 85 | width 20px 86 | 87 | #scenegraph, 88 | #rightPanel 89 | z-index 9998 90 | 91 | #sidebar, 92 | #scenegraph, 93 | .panel 94 | cursor default 95 | user-select none 96 | 97 | .toggle-edit 98 | background-color $red 99 | box-sizing content-box 100 | color #FAFAFA 101 | font-size 13px 102 | left 3px 103 | line-height 16px 104 | margin 0 105 | padding 6px 10px 106 | position fixed 107 | text-align center 108 | text-decoration none 109 | top 3px 110 | width 100px 111 | z-index 999999999 112 | 113 | .toggle-edit:hover 114 | background-color rgb(228, 43, 90) 115 | 116 | .try-editor-btn 117 | background-color $red 118 | box-sizing content-box 119 | color #FAFAFA 120 | font-size 16px 121 | line-height 24px 122 | margin 0 123 | padding 6px 10px 124 | text-align center 125 | text-decoration none 126 | width 200px 127 | display flex 128 | gap 5px 129 | justify-content center 130 | 131 | .try-editor-btn:hover 132 | background-color rgb(228, 43, 90) 133 | color #FAFAFA 134 | 135 | .sponsor-btn 136 | background-color #ffffff 137 | box-sizing content-box 138 | color #000000 139 | font-size 13px 140 | left 127px 141 | line-height 16px 142 | margin 0 143 | padding 6px 10px 144 | position fixed 145 | text-align center 146 | text-decoration none 147 | top 3px 148 | width 80px 149 | z-index 999999999 150 | display flex 151 | gap 5px 152 | justify-content center 153 | 154 | svg 155 | fill currentColor 156 | color rgb(219, 97, 162) 157 | 158 | .sponsor-btn:hover 159 | background-color rgb(228, 43, 90) 160 | color #FAFAFA 161 | 162 | input 163 | background-color transparent 164 | border 1px solid #555 165 | color #fff 166 | 167 | input, 168 | .texture canvas 169 | transition 0.1s background-color ease-in-out, 0.1s border-color ease-in-out, 0.1s color ease-in-out 170 | 171 | input[type=text], 172 | input[type=number], 173 | input.string, 174 | input.number 175 | min-height 14px 176 | outline none 177 | 178 | input[type="checkbox"] 179 | appearance auto 180 | cursor pointer 181 | margin 0 182 | height 18px 183 | width 18px 184 | 185 | input[type="checkbox"]:focus 186 | box-shadow none 187 | 188 | input.number 189 | background-color transparent !important 190 | border 0 191 | color #2cb7ff !important 192 | cursor col-resize 193 | font-size 13px 194 | padding 2px 195 | 196 | input.stringfocus, 197 | input.numberfocus 198 | border 1px solid #20b1fb 199 | color #fff 200 | cursor auto 201 | 202 | input.error 203 | border 1px solid #a00 204 | 205 | #sidebar 206 | background $bg 207 | width 331px 208 | 209 | #sidebar * 210 | vertical-align middle 211 | 212 | input, 213 | textarea, 214 | select 215 | background $black 216 | border 1px solid transparent 217 | color #888 218 | 219 | select 220 | background $bglighter 221 | 222 | input[type=color] 223 | background-color #333 224 | border 1px solid #111 225 | height 28px 226 | cursor pointer 227 | 228 | input[type=color] 229 | cursor pointer 230 | height 25px 231 | padding 0 232 | width 50px 233 | 234 | /* Note these vendor-prefixed selectors cannot be grouped! */ 235 | input[type=color]-webkit-color-swatch 236 | border 0 /* To remove the gray border. */ 237 | 238 | input[type=color]-webkit-color-swatch-wrapper 239 | padding 0 /* To remove the inner padding. */ 240 | 241 | input[type=color]-moz-color-swatch 242 | border 0 243 | 244 | input[type=color]-moz-focus-inner 245 | border 0 /* To remove the inner border (specific to Firefox). */ 246 | padding 0 247 | 248 | .hidden 249 | visibility hidden 250 | 251 | a.button 252 | color #bcbcbc 253 | font-size 16px 254 | margin-left 10px 255 | text-decoration none 256 | 257 | &:hover 258 | color $primary 259 | 260 | @keyframes animateopacity 261 | from { opacity: 0 } 262 | to { opacity: 1 } 263 | 264 | .hide 265 | display none 266 | 267 | .a-canvas.state-dragging 268 | cursor grabbing 269 | 270 | #rightPanel 271 | align-items stretch 272 | display flex 273 | justify-content flex-end 274 | 275 | #inspectorContainer 276 | display flex 277 | justify-content space-between 278 | left 0 279 | height 100% 280 | pointer-events none 281 | position fixed 282 | top 0 283 | width 100% 284 | z-index 999999 285 | 286 | #scenegraph, 287 | #viewportBar, 288 | #rightPanel 289 | pointer-events all 290 | 291 | .aframe-inspector-opened a-scene .a-canvas 292 | background-color #191919 293 | z-index 9998 294 | 295 | .toggle-sidebar 296 | align-items center 297 | display flex 298 | height 100% 299 | position absolute 300 | z-index 9998 301 | 302 | a 303 | background-color #262626 304 | color #bcbcbc 305 | padding 5px 306 | z-index 9998 307 | 308 | a.hover 309 | background-color #1faaf2 310 | color #fff 311 | 312 | .toggle-sidebar.left 313 | top 0 314 | left 0 315 | 316 | .toggle-sidebar.right 317 | top 0 318 | right 0 319 | -------------------------------------------------------------------------------- /src/style/lib.styl: -------------------------------------------------------------------------------- 1 | $primary=#1faaf2 2 | $primaryhover=lighten(#1faaf2, 35%) 3 | 4 | $bg=#242424 5 | $bgdark=#1d1d1d 6 | $bglight=#333 7 | $bglighter=#393939 8 | 9 | $red=#92374d 10 | $green=#514b23 11 | $orange=#d66853 12 | 13 | $black=#222 14 | $gray=#262626 15 | $grayalt=#323232 16 | $grayhover=#444 17 | 18 | $lightgray=#AAA 19 | $white=#c3c3c3 20 | 21 | $normalfont=system-ui, BlinkMacSystemFont, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif 22 | $monospace=system-ui, BlinkMacSystemFont, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif 23 | 24 | media--1024() { 25 | @media (min-width: 1024px) { 26 | {block} 27 | } 28 | } 29 | 30 | /* CSS rules from the original FontAwesomeIcon component */ 31 | svg:not(:root).svg-inline--fa, svg:not(:host).svg-inline--fa { 32 | overflow: visible; 33 | box-sizing: content-box; 34 | } 35 | 36 | .svg-inline--fa { 37 | display: inline-block; 38 | height: 1em; 39 | overflow: visible; 40 | vertical-align: -0.125em; 41 | } -------------------------------------------------------------------------------- /src/style/scenegraph.styl: -------------------------------------------------------------------------------- 1 | @import './lib'; 2 | 3 | #toolbar 4 | background-color $bg 5 | 6 | .toolbarActions 7 | padding 0 0 5px 8 | display flex 9 | align-items baseline 10 | 11 | a.disabled 12 | color #666 13 | cursor default 14 | 15 | .helpButtonContainer 16 | flex-grow 1 17 | padding-right 10px 18 | text-align right 19 | 20 | #scenegraph 21 | background $bg 22 | border-top 1px solid #111 23 | display flex 24 | flex-direction column 25 | overflow auto 26 | padding-top 32px 27 | width 230px 28 | 29 | .entity 30 | background $bg 31 | cursor pointer 32 | display flex 33 | justify-content space-between 34 | padding 3px 35 | width 100% 36 | white-space nowrap 37 | 38 | &:hover 39 | background #1d2f39 40 | 41 | &.active 42 | background-color #155373 43 | color #fff 44 | .component:hover 45 | color #1888c1 46 | .entityActions 47 | display inline 48 | 49 | &.novisible 50 | &.active 51 | span, 52 | svg, 53 | .collapsespace, 54 | .id 55 | color #999 56 | 57 | &:not(.active) 58 | span, 59 | svg, 60 | .collapsespace, 61 | .id 62 | color #626262 63 | 64 | .component:hover 65 | color #1faaf2 66 | 67 | .entityIcons 68 | margin-left 2px 69 | 70 | .entityActions 71 | display none 72 | margin 0 14px 73 | 74 | .button 75 | color #fff 76 | font-size 12px 77 | margin-left 6px 78 | 79 | svg 80 | color #CCC 81 | 82 | .toolbarActions svg:hover, 83 | .entityActions svg:hover 84 | color $primary 85 | 86 | .active svg 87 | color #FAFAFA 88 | 89 | .id 90 | color #ccc 91 | 92 | .option.active .id 93 | color #fff 94 | 95 | .collapsespace 96 | color #eee 97 | display inline-block 98 | text-align center 99 | width 14px 100 | 101 | .fa-eye 102 | color #bbb 103 | 104 | .icons a.button 105 | color #fff 106 | 107 | .search 108 | padding 5px 109 | font-size 16px 110 | position relative 111 | 112 | input 113 | color $white 114 | background $bgdark 115 | border-radius 5px 116 | height 22px 117 | text-indent 10px 118 | width 216px 119 | 120 | >svg, a.button 121 | position absolute 122 | right 14px 123 | top 10px 124 | 125 | .outliner 126 | background $bg 127 | color $white 128 | cursor default 129 | flex 1 1 auto 130 | font-size 13px 131 | height calc(100% - 98px) 132 | line-height normal 133 | outline none 134 | overflow-y auto 135 | padding 0 136 | width 230px 137 | 138 | .scenegraph-bottom 139 | background-color #323232 140 | border-top 1px solid #111 141 | bottom 10 142 | height 40px 143 | left 0 144 | z-index 100 145 | 146 | a 147 | float right 148 | margin 10px 149 | -------------------------------------------------------------------------------- /src/style/select.styl: -------------------------------------------------------------------------------- 1 | @import './lib'; 2 | 3 | .select__control 4 | border 0 5 | border-radius 0 6 | cursor pointer 7 | min-height 26px 8 | font-family $monospace 9 | font-size 13px 10 | 11 | .select__indicator 12 | height 26px 13 | 14 | .select__indicator-separator 15 | display none 16 | 17 | .select__input 18 | min-height auto !important 19 | 20 | .select__control, 21 | .select__menu 22 | background $bgdark 23 | 24 | .select__option 25 | padding 5px 10px 26 | 27 | .select__placeholder, 28 | .select__menu 29 | color $white 30 | 31 | .select__single-value 32 | color $primary 33 | 34 | .select__control--is-focused 35 | box-shadow none !important 36 | 37 | .select__option 38 | cursor pointer 39 | 40 | .select__label 41 | font-size 11px 42 | 43 | .select__option--is-focused 44 | background #155373 45 | 46 | .select__value-container 47 | height 26px 48 | position static 49 | &.select__value-container--is-multi 50 | height auto 51 | padding 6px 52 | 53 | .select__dropdown-indicator 54 | padding 3px 8px 55 | 56 | .select__multi-value 57 | background $bg 58 | color $primary 59 | 60 | .select__multi-value__label 61 | color $primary 62 | 63 | .select__multi-value__remove:hover 64 | color #fff 65 | background $bg 66 | -------------------------------------------------------------------------------- /src/style/textureModal.styl: -------------------------------------------------------------------------------- 1 | .modal 2 | animation animateopacity 0.2s ease-out 3 | background-color rgb(0, 0, 0) 4 | background-color rgba(0, 0, 0, 0.6) 5 | display flex 6 | height 100% 7 | left 0 8 | overflow auto 9 | position fixed 10 | top 0 11 | width 100% 12 | z-index 9999999999 13 | 14 | .modal h3 15 | font-size 18px 16 | font-weight 100 17 | margin 0.6em 0 18 | 19 | #textureModal .modal-content 20 | height calc(100% - 50px) 21 | width calc(100% - 50px) 22 | 23 | .modal-content 24 | animation animatetop 0.2s ease-out 25 | animation-duration 0.2s 26 | animation-name animatetop 27 | background-color #232323 28 | box-shadow 0 4px 8px 0 rgba(0, 0, 0, 0.5), 0 6px 20px 0 rgba(0, 0, 0, 0.5) 29 | margin auto 30 | overflow hidden 31 | padding 0 32 | 33 | .close 34 | color white 35 | float right 36 | font-size 28px 37 | font-weight bold 38 | 39 | .close:hover, 40 | .close:focus 41 | color #08f 42 | cursor pointer 43 | text-decoration none 44 | 45 | .modal-header 46 | color white 47 | padding 2px 16px 48 | 49 | .modal-body 50 | overflow auto 51 | padding 16px 52 | 53 | .modal-footer 54 | color white 55 | padding 2px 16px 56 | 57 | /* Gallery */ 58 | .gallery 59 | background #232323 60 | display flex 61 | flex-wrap wrap 62 | margin 15px auto 0 63 | max-height calc(100vh - 370px) 64 | overflow auto 65 | padding 15px 3px 3px 66 | 67 | .newimage .gallery 68 | padding 16px 69 | 70 | .gallery li 71 | border-radius 2px 72 | box-shadow 0 0 6px rgba(0, 0, 0, 0.6) 73 | cursor pointer 74 | margin 8px 75 | overflow hidden 76 | width 155px 77 | 78 | .gallery li.selected, 79 | .gallery li:hover 80 | box-shadow 0 0 0 2px #1eaaf1 81 | 82 | .gallery li .detail 83 | background-color #323232 84 | margin 0 85 | min-height 60px 86 | padding 3px 10px 87 | 88 | .preview 89 | padding 10px 90 | width 150px 91 | 92 | .preview input 93 | display block 94 | margin 8px 0 95 | width 144px 96 | 97 | .preview button 98 | width 155px 99 | 100 | .preview .detail .title 101 | color #fff 102 | display inline-block 103 | max-width 155px 104 | overflow hidden 105 | text-overflow ellipsis 106 | white-space nowrap 107 | 108 | .gallery li.selected .detail, 109 | .gallery li:hover .detail 110 | background-color #444 111 | 112 | .gallery li .detail span 113 | color #777 114 | display block 115 | margin-top 4px 116 | overflow hidden 117 | text-overflow ellipsis 118 | white-space nowrap 119 | width 140px 120 | 121 | .gallery li.selected .detail span, 122 | .gallery li:hover .detail span 123 | color #888 124 | 125 | .gallery li .detail span.title 126 | color #fff !important 127 | 128 | .modal button 129 | appearance none 130 | border-radius 0 131 | box-shadow none 132 | cursor pointer 133 | display inline-block 134 | font-size 12px 135 | line-height 1.8 136 | margin 0 10px 0 0 137 | padding 5px 10px 138 | 139 | .modal button:focus 140 | outline none 141 | 142 | .modal button 143 | background-color #1eaaf1 144 | border none 145 | color #fff 146 | 147 | .modal button:hover, 148 | .modal button.hover 149 | background-color #346392 150 | text-shadow -1px 1px #27496d 151 | 152 | .modal button:active, 153 | .modal button.active 154 | background-color #27496d 155 | text-shadow -1px 1px #193047 156 | 157 | .modal button:disabled 158 | background-color #888 159 | cursor none 160 | 161 | .newimage 162 | background-color #323232 163 | color #bcbcbc 164 | display flex 165 | font-size 13px 166 | justify-content space-between 167 | margin-top 10px 168 | overflow auto 169 | padding 10px 170 | 171 | .newimage input 172 | color #1eaaf1 173 | padding 3px 5px 174 | 175 | .texture canvas + input 176 | margin-left 5px 177 | 178 | .texture svg 179 | padding-right 5px 180 | 181 | .uploader-normal-button .hidden 182 | display none 183 | 184 | .assets.search 185 | position relative 186 | margin-top 10px 187 | width 200px 188 | 189 | .assets.search svg 190 | position absolute 191 | right 0px 192 | top 5px 193 | 194 | .new_asset_options 195 | margin 10px 196 | 197 | .new_asset_options > ul 198 | margin-left 10px 199 | padding 5px 200 | 201 | .new_asset_options > ul > li 202 | padding 10px 0 203 | 204 | .new_asset_options .imageUrl 205 | margin-left 5px 206 | width 350px 207 | 208 | .texture canvas 209 | border 1px solid $bglight 210 | cursor pointer 211 | -------------------------------------------------------------------------------- /src/style/viewport.styl: -------------------------------------------------------------------------------- 1 | @import './lib'; 2 | 3 | #viewportBar 4 | align-items center 5 | background-color $bg 6 | color $white 7 | display flex 8 | flex-grow 2 9 | height 32px 10 | font-size 15px 11 | justify-content space-between 12 | left 0 13 | margin 0 auto 14 | right 0 15 | top 0 16 | 17 | .toolbarButtons 18 | display flex 19 | align-items center 20 | gap 6px 21 | 22 | * 23 | margin-left 0 !important 24 | vertical-align middle 25 | 26 | a.button 27 | & svg 28 | padding 8px 29 | 30 | &:not(.active) svg:hover 31 | background-color $grayhover 32 | 33 | .active svg 34 | background-color $primary 35 | color #fff 36 | 37 | .active:hover svg 38 | color #fff !important 39 | 40 | .local-transform 41 | padding-left 10px 42 | padding-right 20px 43 | 44 | .local-transform label 45 | color $lightgray 46 | padding-left 5px 47 | 48 | .local-transform a.button 49 | padding-top 0 50 | 51 | #cameraSelect 52 | cursor pointer 53 | width 120px 54 | .select__dropdown-indicator 55 | padding-left 3px 56 | padding-right 3px 57 | 58 | #cameraToolbar 59 | margin-left 5px 60 | align-items center 61 | display flex 62 | a 63 | margin-right 10px 64 | .select__control 65 | background none 66 | .select__single-value 67 | color $white 68 | &:hover 69 | color $primary 70 | 71 | #viewportHud 72 | display none 73 | +media--1024() 74 | display block 75 | -------------------------------------------------------------------------------- /src/style/widgets.styl: -------------------------------------------------------------------------------- 1 | @import './lib'; 2 | 3 | .Select-control 4 | background-color #222 !important 5 | border none 6 | border-radius 0 7 | color $primary 8 | font-family $monosapce 9 | 10 | .Select-menu-outer 11 | border none 12 | 13 | .Select-menu-outer .is-focused 14 | background-color $primary !important 15 | color $white 16 | 17 | .Select-option 18 | background-color #222 !important 19 | 20 | .select-widget 21 | display inline-block 22 | width 157px 23 | 24 | .Select-placeholder, 25 | .Select--single > .Select-control .Select-value 26 | color $primary !important 27 | 28 | .Select-value-label 29 | color $primary !important 30 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | devServer: { 5 | hot: true, 6 | liveReload: false, 7 | port: 3333, 8 | static: { 9 | directory: '.' 10 | } 11 | }, 12 | devtool: 'source-map', 13 | entry: './src/index.js', 14 | output: { 15 | path: path.join(__dirname, 'dist'), 16 | filename: process.env.MINIFY 17 | ? 'aframe-inspector.min.js' 18 | : 'aframe-inspector.js', 19 | publicPath: '/dist/' 20 | }, 21 | externals: { 22 | // Stubs out `import ... from 'three'` so it returns `import ... from window.THREE` effectively using THREE global variable that is defined by AFRAME. 23 | three: 'THREE' 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.js$/, 29 | exclude: /node_modules/, 30 | use: { 31 | loader: 'babel-loader' 32 | } 33 | }, 34 | { 35 | test: /\.svg$/, 36 | type: 'asset/inline' 37 | }, 38 | { 39 | test: /\.css$/, 40 | use: ['style-loader', 'css-loader', 'postcss-loader'] 41 | }, 42 | { 43 | test: /\.styl$/, 44 | exclude: /node_modules/, 45 | use: [ 46 | 'style-loader', 47 | { 48 | loader: 'css-loader', 49 | options: { url: false } 50 | }, 51 | { 52 | loader: 'postcss-loader', 53 | options: { 54 | postcssOptions: { 55 | plugins: ['autoprefixer'] 56 | } 57 | } 58 | }, 59 | 'stylus-loader' 60 | ] 61 | } 62 | ] 63 | }, 64 | mode: process.env.MINIFY === 'true' ? 'production' : 'development' 65 | }; 66 | --------------------------------------------------------------------------------