diff --git a/examples/index.html b/examples/index.html index c14923230..410349c94 100644 --- a/examples/index.html +++ b/examples/index.html @@ -77,6 +77,8 @@ + + @@ -93,6 +95,12 @@ + + + + + + diff --git a/src/components/Main.js b/src/components/Main.js index 93463f192..45ba17ad5 100644 --- a/src/components/Main.js +++ b/src/components/Main.js @@ -4,6 +4,7 @@ import { AwesomeIcon } from './AwesomeIcon'; import Events from '../lib/Events'; import ComponentsSidebar from './components/Sidebar'; import ModalTextures from './modals/ModalTextures'; +import ModalMaterials from './modals/ModalMaterials'; import ModalHelp from './modals/ModalHelp'; import ModalSponsor from './modals/ModalSponsor'; import SceneGraph from './scenegraph/SceneGraph'; @@ -21,6 +22,7 @@ export default class Main extends React.Component { isHelpOpen: false, isModalSponsorOpen: false, isModalTexturesOpen: false, + isModalMaterialsOpen: false, sceneEl: AFRAME.scenes[0], visible: { scenegraph: true, @@ -75,6 +77,17 @@ export default class Main extends React.Component { }.bind(this) ); + Events.on( + 'openmaterialsmodal', + function (selectedMaterial, materialOnClose) { + this.setState({ + selectedMaterial: selectedMaterial, + isModalMaterialsOpen: true, + materialOnClose: materialOnClose + }); + }.bind(this) + ); + Events.on('entityselect', (entity) => { this.setState({ entity: entity }); }); @@ -99,6 +112,13 @@ export default class Main extends React.Component { } }; + onModalMaterialsClose = (value) => { + this.setState({ isModalMaterialsOpen: false }); + if (this.state.materialOnClose) { + this.state.materialOnClose(value); + } + }; + openModalSponsor = () => { this.setState({ isModalSponsorOpen: true }); }; @@ -216,6 +236,14 @@ export default class Main extends React.Component { isOpen={this.state.isModalSponsorOpen} onClose={this.onCloseModalSponsor} /> + + {/* Rendered after ModalMaterials so it stacks on top when the textures + modal is opened from a map property in the materials modal. */} ; only + // show the material property. + const usesMaterialAsset = + this.props.name === 'material' && !!componentData.data.material; + return Object.keys(componentData.schema) .sort() .filter((propertyName) => shouldShowProperty(propertyName, componentData)) + .filter( + (propertyName) => !usesMaterialAsset || propertyName === 'material' + ) .map((propertyName) => ( ; } + if (type === 'material') { + // widgetProps contains onBlur (material is a selector-like type). + return ; + } switch (type) { case 'number': { diff --git a/src/components/modals/Modal.js b/src/components/modals/Modal.js index fc52a145d..240c87972 100644 --- a/src/components/modals/Modal.js +++ b/src/components/modals/Modal.js @@ -31,6 +31,7 @@ export default class Modal extends React.Component { handleGlobalKeydown = (event) => { if ( this.state.isOpen && + this.isTopmostModal() && (event.keyCode === 27 || (this.props.extraCloseKeyCode && event.keyCode === this.props.extraCloseKeyCode)) @@ -42,6 +43,16 @@ export default class Modal extends React.Component { } }; + // Modals can be stacked (e.g., the textures modal opened from the materials + // modal); only the topmost open modal should react to ESC or outside clicks. + isTopmostModal = () => { + const openModals = document.querySelectorAll('.modal:not(.hide)'); + return ( + openModals.length > 0 && + openModals[openModals.length - 1].contains(this.self.current) + ); + }; + shouldClickDismiss = (event) => { var target = event.target; // This piece of code isolates targets which are fake clicked by things @@ -52,6 +63,10 @@ export default class Modal extends React.Component { if (target === this.self.current || this.self.current.contains(target)) { return false; } + // Don't dismiss when interacting with another modal stacked on top. + if (!this.isTopmostModal()) { + return false; + } return true; }; diff --git a/src/components/modals/ModalMaterials.js b/src/components/modals/ModalMaterials.js new file mode 100644 index 000000000..450b608ce --- /dev/null +++ b/src/components/modals/ModalMaterials.js @@ -0,0 +1,325 @@ +/* eslint-disable no-prototype-builtins */ +import React from 'react'; +import PropTypes from 'prop-types'; +import Modal from './Modal'; +import BooleanWidget from '../widgets/BooleanWidget'; +import ColorWidget from '../widgets/ColorWidget'; +import InputWidget from '../widgets/InputWidget'; +import NumberWidget from '../widgets/NumberWidget'; +import SelectWidget from '../widgets/SelectWidget'; +import TextureWidget from '../widgets/TextureWidget'; +import Vec2Widget from '../widgets/Vec2Widget'; + +/** + * Modal listing the assets of the scene. + * + * Acts both as a picker (when opened from the material property widget, the + * selected material can be applied to the property) and as a live editor: + * property changes are set on the element, updating every entity + * sharing the material. + */ +export default class ModalMaterials extends React.Component { + static propTypes = { + isOpen: PropTypes.bool, + onClose: PropTypes.func, + pickEnabled: PropTypes.bool, + selectedMaterial: PropTypes.string + }; + + constructor(props) { + super(props); + this.state = { + isOpen: this.props.isOpen, + materials: [], + selectedEl: null + }; + } + + static getDerivedStateFromProps(props, state) { + if (state.isOpen !== props.isOpen) { + return { isOpen: props.isOpen }; + } + return null; + } + + componentDidMount() { + // Textures load asynchronously; refresh swatches and widgets when they do. + document.addEventListener('materialtextureloaded', this.onTextureLoaded); + } + + componentWillUnmount() { + document.removeEventListener('materialtextureloaded', this.onTextureLoaded); + } + + onTextureLoaded = () => { + if (this.state.isOpen) { + this.forceUpdate(); + } + }; + + componentDidUpdate(prevProps) { + if (this.props.isOpen && !prevProps.isOpen) { + this.refresh(); + } + } + + refresh() { + const materials = Array.from(document.querySelectorAll('a-material')); + const value = this.props.selectedMaterial; + let selectedEl = null; + if (value) { + if (value[0] === '#') { + selectedEl = materials.find((el) => el.id === value.substring(1)); + } else { + selectedEl = materials.find((el) => el.inlineString === value); + } + } + this.setState({ + materials: materials, + selectedEl: selectedEl || materials[0] || null + }); + } + + onClose = () => { + if (this.props.onClose) { + this.props.onClose(); + } + }; + + pick = () => { + const el = this.state.selectedEl; + if (!el || !this.props.onClose) return; + this.props.onClose(el.id ? '#' + el.id : el.inlineString); + }; + + createMaterial = () => { + let n = 1; + while (document.getElementById('material-' + n)) { + n++; + } + const el = document.createElement('a-material'); + el.id = 'material-' + n; + const sceneEl = AFRAME.scenes[0]; + let assetsEl = sceneEl.querySelector('a-assets'); + if (!assetsEl) { + assetsEl = document.createElement('a-assets'); + sceneEl.appendChild(assetsEl); + } + assetsEl.appendChild(el); + this.setState({ + materials: Array.from(document.querySelectorAll('a-material')), + selectedEl: el + }); + }; + + removeMaterial = () => { + const el = this.state.selectedEl; + // Inline materials are owned by their `material(...)` definitions. + if (!el || !el.id) return; + const material = el.getMaterial(); + const users = Array.from( + document.querySelectorAll('a-scene [material]') + ).filter( + (entity) => + entity.isEntity && entity.components?.material?.material === material + ); + const message = users.length + ? 'Material `#' + + el.id + + '` is used by ' + + users.length + + ' entit' + + (users.length === 1 ? 'y' : 'ies') + + '. Do you really want to remove it?' + : 'Do you really want to remove material `#' + el.id + '`?'; + if (!confirm(message)) return; + el.parentNode.removeChild(el); + const materials = Array.from(document.querySelectorAll('a-material')); + this.setState({ materials, selectedEl: materials[0] || null }); + }; + + updateProperty = (name, value) => { + const el = this.state.selectedEl; + const propDef = el.schema[name]; + let stringValue; + if (value === null || value === undefined) { + stringValue = ''; + } else if (typeof value === 'object') { + stringValue = propDef.stringify(value); + } else { + stringValue = String(value); + } + el.setAttribute(name, stringValue); + // Attribute changes are picked up by a MutationObserver (asynchronous); + // apply synchronously so swatches and dependent widgets refresh right away. + el.attributeChangedCallback(name.toLowerCase(), null, stringValue); + this.forceUpdate(); + }; + + getMaterialTitle(el) { + return el.id ? '#' + el.id : el.inlineString || '(inline)'; + } + + renderSwatch(el) { + let color = '#888'; + let imageSrc = null; + const material = el.getMaterial(); + if (material && material.color) { + color = '#' + material.color.getHexString(); + } + const image = material && material.map && material.map.image; + if (image && image.src) { + imageSrc = image.src; + } + return ( + + {imageSrc ? : null} + + ); + } + + renderMaterialsList() { + const materials = this.state.materials; + if (materials.length === 0) { + return No <a-material> assets in the scene.; + } + return ( + + {materials.map((el, idx) => ( + this.setState({ selectedEl: el })} + onDoubleClick={this.props.pickEnabled ? this.pick : undefined} + title={this.getMaterialTitle(el)} + > + {this.renderSwatch(el)} + {this.getMaterialTitle(el)} + + ))} + + ); + } + + renderPropertyRow(el, key) { + const propDef = el.schema[key]; + const value = el.data[key]; + const id = 'materialasset:' + key; + const onChange = (widgetName, widgetValue) => + this.updateProperty(key, widgetValue); + const widgetProps = { id: id, name: key, onChange: onChange, value: value }; + let widget; + + if (key === 'shader') { + // Shader cannot be changed after the material is created. + widget = {el.data.shader}; + } else if (propDef.oneOf && propDef.oneOf.length > 0) { + widget = ; + } else if (propDef.type === 'map') { + // Opens the textures modal, stacked on top of this one. + widget = ; + } else { + switch (propDef.type) { + case 'number': { + widget = ( + + ); + break; + } + case 'int': + case 'time': { + widget = ; + break; + } + case 'vec2': { + widget = ; + break; + } + case 'color': { + widget = ; + break; + } + case 'boolean': { + widget = ; + break; + } + default: { + widget = ( + + ); + } + } + } + + return ( + + + {key} + + {widget} + + ); + } + + renderEditor() { + const el = this.state.selectedEl; + if (!el || !el.schema) return null; + const keys = Object.keys(el.schema).sort(); + return keys.map((key) => this.renderPropertyRow(el, key)); + } + + render() { + return ( + + + + {this.renderMaterialsList()} + + NEW MATERIAL + {this.props.pickEnabled && ( + + USE SELECTED + + )} + + DELETE + + + + {this.renderEditor()} + + + ); + } +} diff --git a/src/components/modals/ModalTextures.js b/src/components/modals/ModalTextures.js index a81c584b5..1c653e3b9 100644 --- a/src/components/modals/ModalTextures.js +++ b/src/components/modals/ModalTextures.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { faSearch } from '@fortawesome/free-solid-svg-icons'; +import { faSearch, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { AwesomeIcon } from '../AwesomeIcon'; import Events from '../../lib/Events'; import Modal from './Modal'; @@ -217,6 +217,18 @@ export default class ModalTextures extends React.Component { this.setState({ filterText: e.target.value }); }; + deleteAsset = (image, event) => { + event.stopPropagation(); + if (!confirm('Do you really want to remove asset `#' + image.id + '`?')) { + return; + } + const assetEl = document.getElementById(image.id); + if (assetEl && assetEl.parentNode) { + assetEl.parentNode.removeChild(assetEl); + } + this.generateFromAssets(); + }; + renderRegistryImages() { var self = this; let selectSample = function (image) { @@ -411,6 +423,13 @@ export default class ModalTextures extends React.Component { {image.width} x {image.height} + + + ); diff --git a/src/components/widgets/MaterialWidget.js b/src/components/widgets/MaterialWidget.js new file mode 100644 index 000000000..61aa43e48 --- /dev/null +++ b/src/components/widgets/MaterialWidget.js @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Events from '../../lib/Events'; + +/** + * Widget for the `material` property type: a reference to an asset + * (e.g., `#myMaterial`) or an inline `material(...)` definition. + * + * The value is edited as a raw string and committed on blur, like selector types. + * The swatch opens the materials modal to pick another material asset or edit the + * existing ones. + */ +export default class MaterialWidget extends React.Component { + static propTypes = { + id: PropTypes.string, + name: PropTypes.string.isRequired, + onBlur: PropTypes.func, + value: PropTypes.string + }; + + static defaultProps = { + value: '' + }; + + constructor(props) { + super(props); + this.state = { value: this.props.value || '' }; + this.input = React.createRef(); + this.canvas = React.createRef(); + } + + componentDidMount() { + this.paintSwatch(); + } + + componentDidUpdate(prevProps) { + if (this.props.value !== prevProps.value) { + this.setState({ value: this.props.value || '' }); + } + this.paintSwatch(); + } + + resolveMaterialEl = (value) => { + if (!value) return null; + if (value[0] === '#') { + const el = document.getElementById(value.substring(1)); + return el && el.isMaterialAsset ? el : null; + } + if (value.indexOf('material(') === 0) { + return ( + Array.from(document.querySelectorAll('a-material')).find( + (el) => el.inlineString === value + ) || null + ); + } + return null; + }; + + paintSwatch = () => { + const canvas = this.canvas.current; + if (!canvas) return; + const context = canvas.getContext('2d'); + context.clearRect(0, 0, canvas.width, canvas.height); + + const materialEl = this.resolveMaterialEl(this.state.value); + if (!materialEl) return; + const material = materialEl.getMaterial(); + if (!material) return; + + if (material.color) { + context.fillStyle = '#' + material.color.getHexString(); + context.fillRect(0, 0, canvas.width, canvas.height); + } + const image = material.map && material.map.image; + if (image && image.width > 0) { + try { + context.drawImage(image, 0, 0, canvas.width, canvas.height); + } catch (e) { + // Some sources (e.g., detached video) cannot be drawn; keep the color. + } + } + }; + + commit = (value) => { + if (this.props.onBlur) { + this.props.onBlur(this.props.name, value); + } + }; + + onChange = (event) => { + this.setState({ value: event.target.value }); + }; + + onBlur = (event) => { + this.commit(event.target.value); + this.paintSwatch(); + }; + + onKeyDown = (event) => { + event.stopPropagation(); + // enter + if (event.keyCode === 13) { + this.input.current.blur(); + } + }; + + openDialog = () => { + Events.emit('openmaterialsmodal', this.state.value, (value) => { + if (value !== undefined && value !== null && value !== this.state.value) { + this.setState({ value: value }); + this.commit(value); + } + // The material may have been edited in the modal without changing the + // reference; repaint once the modal closes. + setTimeout(this.paintSwatch, 0); + }); + }; + + render() { + const materialEl = this.resolveMaterialEl(this.state.value); + const hint = materialEl + ? 'Material asset: ' + + (materialEl.id ? '#' + materialEl.id : materialEl.inlineString) + + '\nClick the swatch to pick or edit materials' + : 'Click the swatch to pick or edit materials'; + + return ( + + + + + ); + } +} diff --git a/src/style/index.styl b/src/style/index.styl index 0981cfc55..1a0422e10 100644 --- a/src/style/index.styl +++ b/src/style/index.styl @@ -57,6 +57,7 @@ body.aframe-inspector-opened @import './help'; @import './select'; @import './textureModal'; + @import './materialModal'; @import './viewport'; @import './widgets'; diff --git a/src/style/materialModal.styl b/src/style/materialModal.styl new file mode 100644 index 000000000..b2cfaa7af --- /dev/null +++ b/src/style/materialModal.styl @@ -0,0 +1,71 @@ +#materialsModal .modal-content + max-width 760px + width calc(100% - 100px) + +.materialsModal + display flex + gap 16px + +.materialsModal .materialsList + display flex + flex-direction column + flex-shrink 0 + width 240px + +.materialsModal .materialsList ul + list-style none + margin 0 + max-height calc(100vh - 320px) + overflow-y auto + padding 0 + +.materialsModal .materialsList li + align-items center + border-radius 2px + cursor pointer + display flex + gap 8px + margin 2px + overflow hidden + padding 6px 8px + user-select none + +.materialsModal .materialsList li.selected, +.materialsModal .materialsList li:hover + box-shadow 0 0 0 2px var(--color-primary) + +.materialsModal .materialsList li .title + color var(--color-property-defined) + font-weight 600 + overflow hidden + text-overflow ellipsis + white-space nowrap + +.materialsModal .materialsActions + display flex + flex-direction column + gap 4px + margin-top 8px + +.materialSwatch + border 1px solid var(--color-base-content) + border-radius 2px + display inline-block + flex-shrink 0 + height 18px + overflow hidden + width 28px + +.materialSwatch img + display block + height 100% + object-fit cover + width 100% + +.materialsModal .materialsEditor + flex-grow 1 + max-height calc(100vh - 280px) + overflow-y auto + +.materialsModal .materialsEditor .propertyRow + padding 2px 10px 2px 0 diff --git a/src/style/textureModal.styl b/src/style/textureModal.styl index 81215d33c..adace9119 100644 --- a/src/style/textureModal.styl +++ b/src/style/textureModal.styl @@ -70,6 +70,7 @@ cursor pointer margin 8px overflow hidden + position relative width 155px .gallery li.selected, @@ -217,3 +218,13 @@ .texture canvas border 1px solid var(--color-base-100) cursor pointer + +.gallery li .delete-asset + bottom 6px + color var(--color-base-content) + cursor pointer + position absolute + right 8px + +.gallery li .delete-asset:hover + color var(--color-primary)
No <a-material> assets in the scene.