diff --git a/docs/commands.md b/docs/commands.md index e1e33c54c..0750c68f3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -133,6 +133,80 @@ AFRAME.INSPECTOR.execute('entityremove', entity); When executed, this emits an `entityremoved` event with `entity`. When undone, this emits an `entitycreated` event with `entity`. +### assetcreate + +Create an asset element in `` (e.g. `a-material`, `a-mixin`, `img`, `audio`, `video`, `a-asset-item`). + +Usage: + +```js +AFRAME.INSPECTOR.execute('assetcreate', { + tagName: 'a-material' +}); +``` + +or with an explicit id, attributes and a callback receiving the created element: + +```js +AFRAME.INSPECTOR.execute( + 'assetcreate', + { + tagName: 'img', + id: 'wood', + attributes: { src: 'wood.png', crossorigin: 'anonymous' } + }, + undefined, + (img) => { console.log('created', img); } +); +``` + +- `tagName`: element tag to create. +- `id`: optional; a unique `-N` id is generated otherwise (e.g. `material-1` for `a-material`). The id stays stable across undo/redo so later commands referencing the asset keep working. +- `attributes`: optional object of attribute name to string value. + +When executed, this emits an `assetcreate` event with the created element. +When undone, this emits an `assetremove` event with the asset id. + +### assetupdate + +Update an attribute of an asset element. Consecutive updates to the same asset attribute are merged in history like entity updates. + +Usage: + +```js +AFRAME.INSPECTOR.execute('assetupdate', { + assetEl: materialEl, // element or id string + attribute: 'color', + value: 'green' // attribute string, or null to remove the attribute +}); +``` + +When executed or undone, this emits an `assetupdate` event with `{assetEl, attribute, value}`. + +Renaming an asset id is supported with `attribute: 'id'`: the command keeps resolving the element across undo/redo, and entities already referencing the applied id are re-resolved. To also update the entities referencing the asset, combine it with `entityupdate` commands in a `multi` command, referencing elements by id string so the payload stays serializable (the rename must be first; its undo runs last and re-resolves the reverted consumer references): + +```js +AFRAME.INSPECTOR.execute('multi', [ + ['assetupdate', { assetEl: 'wood', attribute: 'id', value: 'oak' }], + ['entityupdate', { entity: 'box1', component: 'material', property: 'material', value: '#oak' }] +]); +``` + +### assetremove + +Remove an asset element from ``. The tag name, attributes and position are captured so undo recreates the asset in place. For ``, entities referencing the asset are re-resolved on undo so they use the recreated `THREE.Material` instance. + +Usage: + +```js +AFRAME.INSPECTOR.execute('assetremove', { + assetEl: materialEl // element or id string +}); +``` + +When executed, this emits an `assetremove` event with the asset id. +When undone, this emits an `assetcreate` event with the recreated element. + ### multi Create two entities in a row: @@ -264,7 +338,35 @@ type EntityUpdateCommand = [ }, ]; +type AssetCreatePayload = { + tagName: string; + id?: string; + attributes?: Record; +}; +type AssetCreateCommand = + | ["assetcreate", AssetCreatePayload] + | ["assetcreate", AssetCreatePayload, (el: Element) => void]; + +type AssetUpdateCommand = [ + "assetupdate", + { + assetEl: Element | string; + attribute: string; + value: string | null; + }, +]; + +type AssetRemoveCommand = [ + "assetremove", + { + assetEl: Element | string; + }, +]; + type CommandsForMulti = ( + | AssetCreateCommand + | AssetRemoveCommand + | AssetUpdateCommand | ComponentAddCommand | ComponentRemoveCommand | EntityCreateCommand 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 ae09430db..6eaa98929 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'; @@ -22,6 +23,7 @@ export default class Main extends React.Component { isHelpOpen: false, isModalSponsorOpen: false, isModalTexturesOpen: false, + isModalMaterialsOpen: false, sceneEl: AFRAME.scenes[0], visible: { scenegraph: true, @@ -76,6 +78,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 }); }); @@ -100,6 +113,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 }); }; @@ -220,6 +240,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..62fb8592b --- /dev/null +++ b/src/components/modals/ModalMaterials.js @@ -0,0 +1,406 @@ +/* eslint-disable no-prototype-builtins */ +import React from 'react'; +import PropTypes from 'prop-types'; +import Events from '../../lib/Events'; +import { createUniqueId } from '../../lib/entity'; +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); + Events.on('assetcreate', this.onAssetCreate); + Events.on('assetremove', this.onMaterialAssetsChanged); + // Refresh on undo/redo of material asset updates. + Events.on('assetupdate', this.onMaterialAssetsChanged); + } + + componentWillUnmount() { + document.removeEventListener('materialtextureloaded', this.onTextureLoaded); + Events.off('assetcreate', this.onAssetCreate); + Events.off('assetremove', this.onMaterialAssetsChanged); + Events.off('assetupdate', this.onMaterialAssetsChanged); + } + + onTextureLoaded = () => { + if (this.state.isOpen) { + this.forceUpdate(); + } + }; + + onAssetCreate = (assetEl) => { + if (!assetEl.isMaterialAsset) return; + this.setState({ + materials: Array.from(document.querySelectorAll('a-material')), + selectedEl: assetEl + }); + }; + + onMaterialAssetsChanged = () => { + const materials = Array.from(document.querySelectorAll('a-material')); + const selectedEl = + this.state.selectedEl && materials.includes(this.state.selectedEl) + ? this.state.selectedEl + : materials[0] || null; + this.setState({ materials, selectedEl }); + }; + + 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 = () => { + // Selection of the new material happens via the assetcreate event, + // so redo also reselects it. + AFRAME.INSPECTOR.execute('assetcreate', { tagName: 'a-material' }); + }; + + 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; + AFRAME.INSPECTOR.execute('assetremove', { assetEl: el }); + }; + + renameMaterial = (newIdRaw) => { + const el = this.state.selectedEl; + if (!el || !el.id) return; + const newId = newIdRaw.trim(); + const oldId = el.id; + if (!newId || newId === oldId) { + this.forceUpdate(); + return; + } + if (!/^[A-Za-z][\w-]*$/.test(newId) || document.getElementById(newId)) { + alert('Invalid or already used id: ' + newId); + this.forceUpdate(); + return; + } + // Rename the asset and update every entity referencing it, as one + // undoable step. The rename runs first; its undo (last) re-resolves the + // reverted consumer references. Elements are referenced by id string so + // the multi command payload stays serializable. + const ref = '#' + oldId; + const commands = [ + ['assetupdate', { assetEl: oldId, attribute: 'id', value: newId }] + ]; + document.querySelectorAll('a-scene [material]').forEach((entity) => { + if ( + entity.isEntity && + entity.getDOMAttribute('material')?.material === ref + ) { + if (!entity.id) { + entity.id = createUniqueId(); + } + commands.push([ + 'entityupdate', + { + entity: entity.id, + component: 'material', + property: 'material', + value: '#' + newId + } + ]); + } + }); + if (commands.length === 1) { + AFRAME.INSPECTOR.execute('assetupdate', commands[0][1]); + } else { + AFRAME.INSPECTOR.execute('multi', commands); + } + this.onMaterialAssetsChanged(); + }; + + 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); + } + AFRAME.INSPECTOR.execute('assetupdate', { + assetEl: el, + attribute: name, + value: stringValue + }); + }; + + 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 ( +
+ + {widget} +
+ ); + } + + renderEditor() { + const el = this.state.selectedEl; + if (!el || !el.schema) return null; + const keys = Object.keys(el.schema).sort(); + return ( + <> + {el.id && ( +
+ + this.renameMaterial(String(value))} + value={el.id} + /> +
+ )} + {keys.map((key) => this.renderPropertyRow(el, key))} + + ); + } + + render() { + return ( + +
+
+ {this.renderMaterialsList()} +
+ + {this.props.pickEnabled && ( + + )} + +
+
+
{this.renderEditor()}
+
+
+ ); + } +} diff --git a/src/components/modals/ModalTextures.js b/src/components/modals/ModalTextures.js index a81c584b5..f92b42d70 100644 --- a/src/components/modals/ModalTextures.js +++ b/src/components/modals/ModalTextures.js @@ -1,15 +1,10 @@ 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'; -import { - getFilename, - getIdFromUrl, - insertNewAsset, - isValidId -} from '../../lib/assetsUtils'; +import { getFilename, getIdFromUrl, isValidId } from '../../lib/assetsUtils'; export default class ModalTextures extends React.Component { static propTypes = { @@ -201,14 +196,27 @@ export default class ModalTextures extends React.Component { return; } - insertNewAsset( - 'img', - this.state.preview.name, - this.state.preview.src, - () => { - this.generateFromAssets(); - this.setState({ addNewDialogOpened: false }); - this.clear(); + AFRAME.INSPECTOR.execute( + 'assetcreate', + { + tagName: 'img', + id: this.state.preview.name, + attributes: { + src: this.state.preview.src, + crossorigin: 'anonymous' + } + }, + undefined, + (img) => { + img.addEventListener( + 'load', + () => { + this.generateFromAssets(); + this.setState({ addNewDialogOpened: false }); + this.clear(); + }, + { once: true } + ); } ); }; @@ -217,6 +225,15 @@ 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; + } + AFRAME.INSPECTOR.execute('assetremove', { assetEl: image.id }); + this.generateFromAssets(); + }; + renderRegistryImages() { var self = this; let selectSample = function (image) { @@ -411,6 +428,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..ac01cb491 --- /dev/null +++ b/src/components/widgets/MaterialWidget.js @@ -0,0 +1,159 @@ +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(); + // Repaint when the referenced material asset is edited (including undo/redo). + Events.on('assetupdate', this.paintSwatch); + document.addEventListener('materialtextureloaded', this.paintSwatch); + } + + componentWillUnmount() { + Events.off('assetupdate', this.paintSwatch); + document.removeEventListener('materialtextureloaded', 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/lib/assetsUtils.js b/src/lib/assetsUtils.js index bb416c2d5..ebafeaae2 100644 --- a/src/lib/assetsUtils.js +++ b/src/lib/assetsUtils.js @@ -44,35 +44,3 @@ export function getValidId(name) { .toLowerCase() ); } - -export function insertNewAsset(type, id, src, onLoadedCallback = undefined) { - let element; - switch (type) { - case 'img': - element = document.createElement('img'); - element.id = id; - element.src = src; - element.crossOrigin = 'anonymous'; - break; - } - - if (element) { - element.onload = function () { - if (onLoadedCallback) { - onLoadedCallback(); - } - }; - - let assetsEl = document.querySelector('a-assets'); - if (!assetsEl) { - assetsEl = document.createElement('a-assets'); - var sceneEl = document.querySelector('a-scene'); - if (!sceneEl) { - throw new Error('No a-scene element found to append a-assets to'); - } - sceneEl.appendChild(assetsEl); - } - - assetsEl.appendChild(element); - } -} diff --git a/src/lib/commands/AssetCreateCommand.js b/src/lib/commands/AssetCreateCommand.js new file mode 100644 index 000000000..e0bd51406 --- /dev/null +++ b/src/lib/commands/AssetCreateCommand.js @@ -0,0 +1,87 @@ +import Events from '../Events'; +import { Command } from '../command.js'; + +/** + * Create a new asset element in (e.g., a-material, a-mixin, img, + * audio, video, a-asset-item). + * + * @param editor Editor + * @param payload Object containing: + * - tagName: element tag to create (e.g., 'a-material', 'img'). + * - id: optional; a unique `-N` id is generated otherwise (e.g., + * `material-1` for a-material). The id stays stable across undo/redo so + * later commands referencing the asset keep working. + * - attributes: optional object of attribute name to string value. + * @param callback Optional callback receiving the created element on execute. + * @constructor + */ +export class AssetCreateCommand extends Command { + constructor(editor, payload = null, callback = undefined) { + super(editor); + + this.type = 'assetcreate'; + this.name = 'Create Asset'; + this.callback = callback; + + if (payload === null) return; + + this.tagName = payload.tagName; + this.attributes = payload.attributes ?? {}; + + if (payload.id) { + this.assetId = payload.id; + } else { + const base = this.tagName.startsWith('a-') + ? this.tagName.substring(2) + : this.tagName; + let n = 1; + while (document.getElementById(base + '-' + n)) { + n++; + } + this.assetId = base + '-' + n; + } + } + + execute(nextCommandCallback) { + const sceneEl = AFRAME.scenes[0]; + let assetsEl = sceneEl.querySelector('a-assets'); + if (!assetsEl) { + assetsEl = document.createElement('a-assets'); + sceneEl.appendChild(assetsEl); + } + const assetEl = document.createElement(this.tagName); + assetEl.id = this.assetId; + for (const name in this.attributes) { + assetEl.setAttribute(name, this.attributes[name]); + } + assetsEl.appendChild(assetEl); + Events.emit('assetcreate', assetEl); + this.callback?.(assetEl); + nextCommandCallback?.(assetEl); + return assetEl; + } + + undo(nextCommandCallback) { + const assetEl = document.getElementById(this.assetId); + if (assetEl && assetEl.parentNode) { + assetEl.parentNode.removeChild(assetEl); + Events.emit('assetremove', this.assetId); + } + nextCommandCallback?.(); + } + + toJSON() { + const output = super.toJSON(this); + output.tagName = this.tagName; + output.assetId = this.assetId; + output.attributes = this.attributes; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.tagName = json.tagName; + this.assetId = json.assetId; + this.attributes = json.attributes; + } +} diff --git a/src/lib/commands/AssetRemoveCommand.js b/src/lib/commands/AssetRemoveCommand.js new file mode 100644 index 000000000..7e5279274 --- /dev/null +++ b/src/lib/commands/AssetRemoveCommand.js @@ -0,0 +1,104 @@ +import Events from '../Events'; +import { Command } from '../command.js'; + +/** + * Remove an asset element from (e.g., a-material, a-mixin, img, + * audio, video, a-asset-item). + * + * The tag name, attributes and position are captured so undo can recreate the + * asset in place. For , entities referencing the asset are + * re-resolved on undo so they use the recreated THREE.Material instance. + * + * @param editor Editor + * @param payload Object containing assetEl (element or ID string). + * @constructor + */ +export class AssetRemoveCommand extends Command { + constructor(editor, payload = null) { + super(editor); + + this.type = 'assetremove'; + this.name = 'Remove Asset'; + + if (payload === null) return; + + let assetEl; + if (typeof payload.assetEl === 'string') { + assetEl = document.getElementById(payload.assetEl); + if (!assetEl) { + console.error('Asset not found with ID:', payload.assetEl); + return; + } + } else { + assetEl = payload.assetEl; + } + this.assetId = assetEl.id; + this.tagName = assetEl.tagName.toLowerCase(); + // Capture attributes and position for undo. + this.attributes = {}; + for (const attr of assetEl.attributes) { + if (attr.name === 'id') continue; + this.attributes[attr.name] = attr.value; + } + this.index = assetEl.parentNode + ? Array.prototype.indexOf.call(assetEl.parentNode.children, assetEl) + : -1; + } + + execute(nextCommandCallback) { + const assetEl = document.getElementById(this.assetId); + if (assetEl && assetEl.parentNode) { + assetEl.parentNode.removeChild(assetEl); + Events.emit('assetremove', this.assetId); + } + nextCommandCallback?.(); + } + + undo(nextCommandCallback) { + const sceneEl = AFRAME.scenes[0]; + let assetsEl = sceneEl.querySelector('a-assets'); + if (!assetsEl) { + assetsEl = document.createElement('a-assets'); + sceneEl.appendChild(assetsEl); + } + const assetEl = document.createElement(this.tagName); + assetEl.id = this.assetId; + for (const name in this.attributes) { + assetEl.setAttribute(name, this.attributes[name]); + } + assetsEl.insertBefore(assetEl, assetsEl.children[this.index] ?? null); + Events.emit('assetcreate', assetEl); + + // The recreated is a new THREE.Material instance; re-resolve + // the entities referencing it so they pick it up. + if (assetEl.isMaterialAsset) { + const ref = '#' + this.assetId; + document.querySelectorAll('a-scene [material]').forEach((entity) => { + if ( + entity.isEntity && + entity.getDOMAttribute('material')?.material === ref + ) { + entity.setAttribute('material', 'material', ref); + } + }); + } + nextCommandCallback?.(assetEl); + } + + toJSON() { + const output = super.toJSON(this); + output.tagName = this.tagName; + output.assetId = this.assetId; + output.attributes = this.attributes; + output.index = this.index; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.tagName = json.tagName; + this.assetId = json.assetId; + this.attributes = json.attributes; + this.index = json.index; + } +} diff --git a/src/lib/commands/AssetUpdateCommand.js b/src/lib/commands/AssetUpdateCommand.js new file mode 100644 index 000000000..619b0a85a --- /dev/null +++ b/src/lib/commands/AssetUpdateCommand.js @@ -0,0 +1,155 @@ +import Events from '../Events'; +import { Command } from '../command.js'; + +/** + * Update an attribute of an asset element in (e.g., a-material, + * a-mixin, img, audio, video, a-asset-item). + * + * Consecutive updates to the same asset attribute are merged in history like + * entity updates (entityId/component/property are set for that purpose). + * + * @param editor Editor + * @param payload Object containing assetEl (element or ID string), attribute + * and value (attribute string, or null to remove the attribute). + * @constructor + */ +export class AssetUpdateCommand extends Command { + constructor(editor, payload = null) { + super(editor); + + this.type = 'assetupdate'; + this.name = 'Update Asset'; + this.updatable = true; + + if (payload === null) return; + + let assetEl; + if (typeof payload.assetEl === 'string') { + assetEl = document.getElementById(payload.assetEl); + if (!assetEl) { + console.error('Asset not found with ID:', payload.assetEl); + return; + } + } else { + assetEl = payload.assetEl; + } + // Inline materials (`material(...)`) have no id; keep a direct reference + // for those, and use the inline string as history merge key. + this.assetEl = assetEl; + this.entityId = assetEl.id || assetEl.inlineString; + this.component = assetEl.tagName.toLowerCase(); + this.property = payload.attribute; + this.newValue = payload.value; + this.oldValue = assetEl.getAttribute(payload.attribute); + } + + resolveAssetEl() { + if (this.assetEl && this.assetEl.isConnected) { + return this.assetEl; + } + return document.getElementById(this.entityId) || this.assetEl; + } + + apply(value, nextCommandCallback) { + if (this.property === 'id') { + this.applyId(value, nextCommandCallback); + return; + } + const assetEl = this.resolveAssetEl(); + if (!assetEl) return; + if (value === null || value === undefined) { + assetEl.removeAttribute(this.property); + } else { + assetEl.setAttribute(this.property, value); + } + // For A-Frame elements like , attribute changes are picked up + // by a MutationObserver (asynchronous); apply synchronously so the UI can + // refresh right away. + if (typeof assetEl.attributeChangedCallback === 'function') { + assetEl.attributeChangedCallback( + this.property.toLowerCase(), + null, + value ?? null + ); + } + Events.emit('assetupdate', { + assetEl, + attribute: this.property, + value + }); + nextCommandCallback?.(assetEl); + } + + /** + * Rename the asset. The command's own entityId follows the rename so + * undo/redo keep resolving the element, and entities already referencing + * the applied id are re-resolved (undo reverts the consumers before the id + * itself is restored). + */ + applyId(value, nextCommandCallback) { + const currentId = value === this.newValue ? this.oldValue : this.newValue; + const assetEl = + document.getElementById(currentId) || + (this.assetEl && this.assetEl.isConnected ? this.assetEl : null); + if (!assetEl) return; + assetEl.id = value; + if (assetEl.isMaterialAsset && assetEl.material) { + assetEl.material.name = value; + } + this.assetEl = assetEl; + this.entityId = value; + const ref = '#' + value; + document.querySelectorAll('a-scene [material]').forEach((entity) => { + if ( + entity.isEntity && + entity.getDOMAttribute('material')?.material === ref + ) { + entity.setAttribute('material', 'material', ref); + } + }); + Events.emit('assetupdate', { + assetEl, + attribute: 'id', + value + }); + nextCommandCallback?.(assetEl); + } + + execute(nextCommandCallback) { + if (this.editor.config.debugUndoRedo) { + console.log('execute', this.entityId, this.property, this.newValue); + } + this.apply(this.newValue, nextCommandCallback); + } + + undo(nextCommandCallback) { + if (this.editor.config.debugUndoRedo) { + console.log('undo', this.entityId, this.property, this.oldValue); + } + this.apply(this.oldValue, nextCommandCallback); + } + + update(command) { + this.newValue = command.newValue; + } + + toJSON() { + const output = super.toJSON(this); + output.entityId = this.entityId; + output.component = this.component; + output.property = this.property; + output.oldValue = this.oldValue; + output.newValue = this.newValue; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.entityId = json.entityId; + this.component = json.component; + this.property = json.property; + this.oldValue = json.oldValue; + this.newValue = json.newValue; + this.assetEl = document.getElementById(this.entityId); + } +} diff --git a/src/lib/commands/EntityUpdateCommand.js b/src/lib/commands/EntityUpdateCommand.js index 7d8d96f56..cea445a83 100644 --- a/src/lib/commands/EntityUpdateCommand.js +++ b/src/lib/commands/EntityUpdateCommand.js @@ -46,9 +46,13 @@ export class EntityUpdateCommand extends Command { if (payload.property) { if (component.schema[payload.property]) { const schemaProperty = component.schema[payload.property]; + // The material type is edited as a raw reference string + // (`#myMaterial` or an inline `material(...)` definition), like + // selector types. const isSelectorType = schemaProperty.type === 'selector' || - schemaProperty.type === 'selectorAll'; + schemaProperty.type === 'selectorAll' || + schemaProperty.type === 'material'; this.newValue = payload.value === null ? null diff --git a/src/lib/commands/index.js b/src/lib/commands/index.js index 4d3be3fdd..cc4a46593 100644 --- a/src/lib/commands/index.js +++ b/src/lib/commands/index.js @@ -1,3 +1,6 @@ +import { AssetCreateCommand } from './AssetCreateCommand.js'; +import { AssetRemoveCommand } from './AssetRemoveCommand.js'; +import { AssetUpdateCommand } from './AssetUpdateCommand.js'; import { ComponentAddCommand } from './ComponentAddCommand.js'; import { ComponentRemoveCommand } from './ComponentRemoveCommand.js'; import { EntityCloneCommand } from './EntityCloneCommand.js'; @@ -14,5 +17,8 @@ commandsByType.set('entityclone', EntityCloneCommand); commandsByType.set('entitycreate', EntityCreateCommand); commandsByType.set('entityremove', EntityRemoveCommand); commandsByType.set('entityreparent', EntityReparentCommand); +commandsByType.set('assetcreate', AssetCreateCommand); +commandsByType.set('assetremove', AssetRemoveCommand); +commandsByType.set('assetupdate', AssetUpdateCommand); commandsByType.set('entityupdate', EntityUpdateCommand); commandsByType.set('multi', MultiCommand); 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)