Skip to content

Commit a88fc24

Browse files
vincentfretinclaude
andcommitted
Route asset editing through generic asset commands for undo/redo
- New AssetCreateCommand ('assetcreate'): creates any asset element in <a-assets> (a-material, a-mixin, img, audio, video, a-asset-item...) with optional attributes; a unique `<base>-N` id is generated when none is given and stays stable across undo/redo. - New AssetUpdateCommand ('assetupdate'): updates an asset attribute, storing raw old/new attribute strings; consecutive edits of the same attribute merge in history like entity updates. Inline materials (no id) are referenced directly and keyed by their inline string. - EntityUpdateCommand treats the material property type like selector types (raw reference string as old/new value). - ModalMaterials executes commands instead of mutating directly and refreshes on assetcreate/assetremove/assetupdate events, so it also reflects undo/redo while open. MaterialWidget repaints its swatch on asset updates. - ModalTextures now creates new image assets through 'assetcreate' (undoable) instead of the direct insertNewAsset helper, now removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5b8809e commit a88fc24

8 files changed

Lines changed: 275 additions & 69 deletions

File tree

src/components/modals/ModalMaterials.js

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable no-prototype-builtins */
22
import React from 'react';
33
import PropTypes from 'prop-types';
4+
import Events from '../../lib/Events';
45
import Modal from './Modal';
56
import BooleanWidget from '../widgets/BooleanWidget';
67
import ColorWidget from '../widgets/ColorWidget';
@@ -45,10 +46,17 @@ export default class ModalMaterials extends React.Component {
4546
componentDidMount() {
4647
// Textures load asynchronously; refresh swatches and widgets when they do.
4748
document.addEventListener('materialtextureloaded', this.onTextureLoaded);
49+
Events.on('assetcreate', this.onAssetCreate);
50+
Events.on('assetremove', this.onMaterialAssetsChanged);
51+
// Refresh on undo/redo of material asset updates.
52+
Events.on('assetupdate', this.onMaterialAssetsChanged);
4853
}
4954

5055
componentWillUnmount() {
5156
document.removeEventListener('materialtextureloaded', this.onTextureLoaded);
57+
Events.off('assetcreate', this.onAssetCreate);
58+
Events.off('assetremove', this.onMaterialAssetsChanged);
59+
Events.off('assetupdate', this.onMaterialAssetsChanged);
5260
}
5361

5462
onTextureLoaded = () => {
@@ -57,6 +65,23 @@ export default class ModalMaterials extends React.Component {
5765
}
5866
};
5967

68+
onAssetCreate = (assetEl) => {
69+
if (!assetEl.isMaterialAsset) return;
70+
this.setState({
71+
materials: Array.from(document.querySelectorAll('a-material')),
72+
selectedEl: assetEl
73+
});
74+
};
75+
76+
onMaterialAssetsChanged = () => {
77+
const materials = Array.from(document.querySelectorAll('a-material'));
78+
const selectedEl =
79+
this.state.selectedEl && materials.includes(this.state.selectedEl)
80+
? this.state.selectedEl
81+
: materials[0] || null;
82+
this.setState({ materials, selectedEl });
83+
};
84+
6085
componentDidUpdate(prevProps) {
6186
if (this.props.isOpen && !prevProps.isOpen) {
6287
this.refresh();
@@ -93,23 +118,9 @@ export default class ModalMaterials extends React.Component {
93118
};
94119

95120
createMaterial = () => {
96-
let n = 1;
97-
while (document.getElementById('material-' + n)) {
98-
n++;
99-
}
100-
const el = document.createElement('a-material');
101-
el.id = 'material-' + n;
102-
const sceneEl = AFRAME.scenes[0];
103-
let assetsEl = sceneEl.querySelector('a-assets');
104-
if (!assetsEl) {
105-
assetsEl = document.createElement('a-assets');
106-
sceneEl.appendChild(assetsEl);
107-
}
108-
assetsEl.appendChild(el);
109-
this.setState({
110-
materials: Array.from(document.querySelectorAll('a-material')),
111-
selectedEl: el
112-
});
121+
// Selection of the new material happens via the assetcreate event,
122+
// so redo also reselects it.
123+
AFRAME.INSPECTOR.execute('assetcreate', { tagName: 'a-material' });
113124
};
114125

115126
updateProperty = (name, value) => {
@@ -123,11 +134,11 @@ export default class ModalMaterials extends React.Component {
123134
} else {
124135
stringValue = String(value);
125136
}
126-
el.setAttribute(name, stringValue);
127-
// Attribute changes are picked up by a MutationObserver (asynchronous);
128-
// apply synchronously so swatches and dependent widgets refresh right away.
129-
el.attributeChangedCallback(name.toLowerCase(), null, stringValue);
130-
this.forceUpdate();
137+
AFRAME.INSPECTOR.execute('assetupdate', {
138+
assetEl: el,
139+
attribute: name,
140+
value: stringValue
141+
});
131142
};
132143

133144
getMaterialTitle(el) {

src/components/modals/ModalTextures.js

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,7 @@ import { faSearch } from '@fortawesome/free-solid-svg-icons';
44
import { AwesomeIcon } from '../AwesomeIcon';
55
import Events from '../../lib/Events';
66
import Modal from './Modal';
7-
import {
8-
getFilename,
9-
getIdFromUrl,
10-
insertNewAsset,
11-
isValidId
12-
} from '../../lib/assetsUtils';
7+
import { getFilename, getIdFromUrl, isValidId } from '../../lib/assetsUtils';
138

149
export default class ModalTextures extends React.Component {
1510
static propTypes = {
@@ -201,14 +196,27 @@ export default class ModalTextures extends React.Component {
201196
return;
202197
}
203198

204-
insertNewAsset(
205-
'img',
206-
this.state.preview.name,
207-
this.state.preview.src,
208-
() => {
209-
this.generateFromAssets();
210-
this.setState({ addNewDialogOpened: false });
211-
this.clear();
199+
AFRAME.INSPECTOR.execute(
200+
'assetcreate',
201+
{
202+
tagName: 'img',
203+
id: this.state.preview.name,
204+
attributes: {
205+
src: this.state.preview.src,
206+
crossorigin: 'anonymous'
207+
}
208+
},
209+
undefined,
210+
(img) => {
211+
img.addEventListener(
212+
'load',
213+
() => {
214+
this.generateFromAssets();
215+
this.setState({ addNewDialogOpened: false });
216+
this.clear();
217+
},
218+
{ once: true }
219+
);
212220
}
213221
);
214222
};

src/components/widgets/MaterialWidget.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export default class MaterialWidget extends React.Component {
3131

3232
componentDidMount() {
3333
this.paintSwatch();
34+
// Repaint when the referenced material asset is edited (including undo/redo).
35+
Events.on('assetupdate', this.paintSwatch);
36+
document.addEventListener('materialtextureloaded', this.paintSwatch);
37+
}
38+
39+
componentWillUnmount() {
40+
Events.off('assetupdate', this.paintSwatch);
41+
document.removeEventListener('materialtextureloaded', this.paintSwatch);
3442
}
3543

3644
componentDidUpdate(prevProps) {

src/lib/assetsUtils.js

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -44,35 +44,3 @@ export function getValidId(name) {
4444
.toLowerCase()
4545
);
4646
}
47-
48-
export function insertNewAsset(type, id, src, onLoadedCallback = undefined) {
49-
let element;
50-
switch (type) {
51-
case 'img':
52-
element = document.createElement('img');
53-
element.id = id;
54-
element.src = src;
55-
element.crossOrigin = 'anonymous';
56-
break;
57-
}
58-
59-
if (element) {
60-
element.onload = function () {
61-
if (onLoadedCallback) {
62-
onLoadedCallback();
63-
}
64-
};
65-
66-
let assetsEl = document.querySelector('a-assets');
67-
if (!assetsEl) {
68-
assetsEl = document.createElement('a-assets');
69-
var sceneEl = document.querySelector('a-scene');
70-
if (!sceneEl) {
71-
throw new Error('No a-scene element found to append a-assets to');
72-
}
73-
sceneEl.appendChild(assetsEl);
74-
}
75-
76-
assetsEl.appendChild(element);
77-
}
78-
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Events from '../Events';
2+
import { Command } from '../command.js';
3+
4+
/**
5+
* Create a new asset element in <a-assets> (e.g., a-material, a-mixin, img,
6+
* audio, video, a-asset-item).
7+
*
8+
* @param editor Editor
9+
* @param payload Object containing:
10+
* - tagName: element tag to create (e.g., 'a-material', 'img').
11+
* - id: optional; a unique `<base>-N` id is generated otherwise (e.g.,
12+
* `material-1` for a-material). The id stays stable across undo/redo so
13+
* later commands referencing the asset keep working.
14+
* - attributes: optional object of attribute name to string value.
15+
* @param callback Optional callback receiving the created element on execute.
16+
* @constructor
17+
*/
18+
export class AssetCreateCommand extends Command {
19+
constructor(editor, payload = null, callback = undefined) {
20+
super(editor);
21+
22+
this.type = 'assetcreate';
23+
this.name = 'Create Asset';
24+
this.callback = callback;
25+
26+
if (payload === null) return;
27+
28+
this.tagName = payload.tagName;
29+
this.attributes = payload.attributes ?? {};
30+
31+
if (payload.id) {
32+
this.assetId = payload.id;
33+
} else {
34+
const base = this.tagName.startsWith('a-')
35+
? this.tagName.substring(2)
36+
: this.tagName;
37+
let n = 1;
38+
while (document.getElementById(base + '-' + n)) {
39+
n++;
40+
}
41+
this.assetId = base + '-' + n;
42+
}
43+
}
44+
45+
execute(nextCommandCallback) {
46+
const sceneEl = AFRAME.scenes[0];
47+
let assetsEl = sceneEl.querySelector('a-assets');
48+
if (!assetsEl) {
49+
assetsEl = document.createElement('a-assets');
50+
sceneEl.appendChild(assetsEl);
51+
}
52+
const assetEl = document.createElement(this.tagName);
53+
assetEl.id = this.assetId;
54+
for (const name in this.attributes) {
55+
assetEl.setAttribute(name, this.attributes[name]);
56+
}
57+
assetsEl.appendChild(assetEl);
58+
Events.emit('assetcreate', assetEl);
59+
this.callback?.(assetEl);
60+
nextCommandCallback?.(assetEl);
61+
return assetEl;
62+
}
63+
64+
undo(nextCommandCallback) {
65+
const assetEl = document.getElementById(this.assetId);
66+
if (assetEl && assetEl.parentNode) {
67+
assetEl.parentNode.removeChild(assetEl);
68+
Events.emit('assetremove', this.assetId);
69+
}
70+
nextCommandCallback?.();
71+
}
72+
73+
toJSON() {
74+
const output = super.toJSON(this);
75+
output.tagName = this.tagName;
76+
output.assetId = this.assetId;
77+
output.attributes = this.attributes;
78+
return output;
79+
}
80+
81+
fromJSON(json) {
82+
super.fromJSON(json);
83+
this.tagName = json.tagName;
84+
this.assetId = json.assetId;
85+
this.attributes = json.attributes;
86+
}
87+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import Events from '../Events';
2+
import { Command } from '../command.js';
3+
4+
/**
5+
* Update an attribute of an asset element in <a-assets> (e.g., a-material,
6+
* a-mixin, img, audio, video, a-asset-item).
7+
*
8+
* Consecutive updates to the same asset attribute are merged in history like
9+
* entity updates (entityId/component/property are set for that purpose).
10+
*
11+
* @param editor Editor
12+
* @param payload Object containing assetEl (element or ID string), attribute
13+
* and value (attribute string, or null to remove the attribute).
14+
* @constructor
15+
*/
16+
export class AssetUpdateCommand extends Command {
17+
constructor(editor, payload = null) {
18+
super(editor);
19+
20+
this.type = 'assetupdate';
21+
this.name = 'Update Asset';
22+
this.updatable = true;
23+
24+
if (payload === null) return;
25+
26+
let assetEl;
27+
if (typeof payload.assetEl === 'string') {
28+
assetEl = document.getElementById(payload.assetEl);
29+
if (!assetEl) {
30+
console.error('Asset not found with ID:', payload.assetEl);
31+
return;
32+
}
33+
} else {
34+
assetEl = payload.assetEl;
35+
}
36+
// Inline materials (`material(...)`) have no id; keep a direct reference
37+
// for those, and use the inline string as history merge key.
38+
this.assetEl = assetEl;
39+
this.entityId = assetEl.id || assetEl.inlineString;
40+
this.component = assetEl.tagName.toLowerCase();
41+
this.property = payload.attribute;
42+
this.newValue = payload.value;
43+
this.oldValue = assetEl.getAttribute(payload.attribute);
44+
}
45+
46+
resolveAssetEl() {
47+
if (this.assetEl && this.assetEl.isConnected) {
48+
return this.assetEl;
49+
}
50+
return document.getElementById(this.entityId) || this.assetEl;
51+
}
52+
53+
apply(value, nextCommandCallback) {
54+
const assetEl = this.resolveAssetEl();
55+
if (!assetEl) return;
56+
if (value === null || value === undefined) {
57+
assetEl.removeAttribute(this.property);
58+
} else {
59+
assetEl.setAttribute(this.property, value);
60+
}
61+
// For A-Frame elements like <a-material>, attribute changes are picked up
62+
// by a MutationObserver (asynchronous); apply synchronously so the UI can
63+
// refresh right away.
64+
if (typeof assetEl.attributeChangedCallback === 'function') {
65+
assetEl.attributeChangedCallback(
66+
this.property.toLowerCase(),
67+
null,
68+
value ?? null
69+
);
70+
}
71+
Events.emit('assetupdate', {
72+
assetEl,
73+
attribute: this.property,
74+
value
75+
});
76+
nextCommandCallback?.(assetEl);
77+
}
78+
79+
execute(nextCommandCallback) {
80+
if (this.editor.config.debugUndoRedo) {
81+
console.log('execute', this.entityId, this.property, this.newValue);
82+
}
83+
this.apply(this.newValue, nextCommandCallback);
84+
}
85+
86+
undo(nextCommandCallback) {
87+
if (this.editor.config.debugUndoRedo) {
88+
console.log('undo', this.entityId, this.property, this.oldValue);
89+
}
90+
this.apply(this.oldValue, nextCommandCallback);
91+
}
92+
93+
update(command) {
94+
this.newValue = command.newValue;
95+
}
96+
97+
toJSON() {
98+
const output = super.toJSON(this);
99+
output.entityId = this.entityId;
100+
output.component = this.component;
101+
output.property = this.property;
102+
output.oldValue = this.oldValue;
103+
output.newValue = this.newValue;
104+
return output;
105+
}
106+
107+
fromJSON(json) {
108+
super.fromJSON(json);
109+
this.entityId = json.entityId;
110+
this.component = json.component;
111+
this.property = json.property;
112+
this.oldValue = json.oldValue;
113+
this.newValue = json.newValue;
114+
this.assetEl = document.getElementById(this.entityId);
115+
}
116+
}

0 commit comments

Comments
 (0)