From 2820409073d07a968a4d142ad0dd0562271dc5b0 Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sun, 22 Feb 2026 09:48:57 +0100 Subject: [PATCH 1/2] Make all commands and undo/redo stack serializable (fix #25) --- src/lib/command.js | 16 ++++++ src/lib/commands/ComponentAddCommand.js | 49 ++++++++++++++--- src/lib/commands/ComponentRemoveCommand.js | 61 +++++++++++++++++----- src/lib/commands/EntityCloneCommand.js | 50 ++++++++++++++---- src/lib/commands/EntityCreateCommand.js | 47 +++++++++++------ src/lib/commands/EntityRemoveCommand.js | 46 ++++++++++++++-- src/lib/commands/EntityReparentCommand.js | 37 +++++++++++-- src/lib/commands/EntityUpdateCommand.js | 56 +++++++++++++++----- src/lib/commands/MultiCommand.js | 25 +++++++-- src/lib/history.js | 61 ++++++++++++++++++++++ 10 files changed, 379 insertions(+), 69 deletions(-) diff --git a/src/lib/command.js b/src/lib/command.js index 9c2fdd25..62286d8b 100644 --- a/src/lib/command.js +++ b/src/lib/command.js @@ -7,9 +7,25 @@ export class Command { constructor(editor) { this.id = -1; + this.inMemory = false; this.updatable = false; this.type = ''; this.name = ''; this.editor = editor; } + + toJSON() { + const output = {}; + output.type = this.type; + output.id = this.id; + output.name = this.name; + return output; + } + + fromJSON(json) { + this.inMemory = true; + this.type = json.type; + this.id = json.id; + this.name = json.name; + } } diff --git a/src/lib/commands/ComponentAddCommand.js b/src/lib/commands/ComponentAddCommand.js index 21e8fd84..8bbbc0a8 100644 --- a/src/lib/commands/ComponentAddCommand.js +++ b/src/lib/commands/ComponentAddCommand.js @@ -2,21 +2,41 @@ import Events from '../Events.js'; import { Command } from '../command.js'; import { createUniqueId } from '../entity.js'; +/** + * Command to add a component to an entity + * @param editor Editor + * @param payload Object containing entity (element or ID string), component, and value + * @constructor + */ export class ComponentAddCommand extends Command { - constructor(editor, payload) { + constructor(editor, payload = null) { super(editor); this.type = 'componentadd'; this.name = 'Add Component'; this.updatable = false; - const entity = payload.entity; - if (!entity.id) { - entity.id = createUniqueId(); + if (payload !== null) { + // Handle case where entity is passed as ID string when used with multi command + let entity; + if (typeof payload.entity === 'string') { + entity = document.querySelector(`#${payload.entity}:not(a-mixin)`); + if (!entity) { + console.error('Entity not found with ID:', payload.entity); + return; + } + this.entityId = payload.entity; + } else { + entity = payload.entity; + if (!entity.id) { + entity.id = createUniqueId(); + } + this.entityId = entity.id; + } + + this.component = payload.component; + this.value = payload.value; } - this.entityId = entity.id; - this.component = payload.component; - this.value = payload.value; } execute(nextCommandCallback) { @@ -43,4 +63,19 @@ export class ComponentAddCommand extends Command { nextCommandCallback?.(entity); } } + + toJSON() { + const output = super.toJSON(this); + output.entityId = this.entityId; + output.component = this.component; + output.value = this.value; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.entityId = json.entityId; + this.component = json.component; + this.value = json.value; + } } diff --git a/src/lib/commands/ComponentRemoveCommand.js b/src/lib/commands/ComponentRemoveCommand.js index 07d895f0..ba28b0a5 100644 --- a/src/lib/commands/ComponentRemoveCommand.js +++ b/src/lib/commands/ComponentRemoveCommand.js @@ -2,27 +2,47 @@ import Events from '../Events.js'; import { Command } from '../command.js'; import { createUniqueId } from '../entity.js'; +/** + * Command to remove a component from an entity + * @param editor Editor + * @param payload Object containing entity (element or ID string) and component + * @constructor + */ export class ComponentRemoveCommand extends Command { - constructor(editor, payload) { + constructor(editor, payload = null) { super(editor); this.type = 'componentremove'; this.name = 'Remove Component'; this.updatable = false; - const entity = payload.entity; - if (!entity.id) { - entity.id = createUniqueId(); + if (payload !== null) { + // Handle case where entity is passed as ID string when used with multi command + let entity; + if (typeof payload.entity === 'string') { + entity = document.querySelector(`#${payload.entity}:not(a-mixin)`); + if (!entity) { + console.error('Entity not found with ID:', payload.entity); + return; + } + this.entityId = payload.entity; + } else { + entity = payload.entity; + if (!entity.id) { + entity.id = createUniqueId(); + } + this.entityId = entity.id; + } + + this.component = payload.component; + + const component = + entity.components[payload.component] ?? + AFRAME.components[payload.component]; + this.value = component.isSingleProperty + ? component.schema.stringify(entity.getAttribute(payload.component)) + : structuredClone(entity.getDOMAttribute(payload.component)); } - this.entityId = entity.id; - this.component = payload.component; - - const component = - entity.components[payload.component] ?? - AFRAME.components[payload.component]; - this.value = component.isSingleProperty - ? component.schema.stringify(entity.getAttribute(payload.component)) - : structuredClone(entity.getDOMAttribute(payload.component)); } execute(nextCommandCallback) { @@ -49,4 +69,19 @@ export class ComponentRemoveCommand extends Command { nextCommandCallback?.(entity); } } + + toJSON() { + const output = super.toJSON(this); + output.entityId = this.entityId; + output.component = this.component; + output.value = this.value; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.entityId = json.entityId; + this.component = json.component; + this.value = json.value; + } } diff --git a/src/lib/commands/EntityCloneCommand.js b/src/lib/commands/EntityCloneCommand.js index 7000839c..a698f6d3 100644 --- a/src/lib/commands/EntityCloneCommand.js +++ b/src/lib/commands/EntityCloneCommand.js @@ -1,20 +1,29 @@ import Events from '../Events.js'; import { Command } from '../command.js'; -import { cloneEntityImpl, createUniqueId, insertAfter } from '../entity.js'; +import { + cloneEntityImpl, + createUniqueId, + elementToObject, + insertAfter, + objectToElement +} from '../entity.js'; export class EntityCloneCommand extends Command { - constructor(editor, entity) { + constructor(editor, entity = null) { super(editor); this.type = 'entityclone'; this.name = 'Clone Entity'; this.updatable = false; - if (!entity.id) { - entity.id = createUniqueId(); - } - this.entityIdToClone = entity.id; + this.entityId = null; - this.detachedClone = null; + if (entity !== null) { + if (!entity.id) { + entity.id = createUniqueId(); + } + this.entityIdToClone = entity.id; + this.detachedClone = null; + } } execute(nextCommandCallback) { @@ -29,13 +38,14 @@ export class EntityCloneCommand extends Command { if (!this.detachedClone) { this.detachedClone = cloneEntityImpl(entityToClone); } + if (this.detachedClone === null) return; const clone = this.detachedClone.cloneNode(true); clone.addEventListener( 'loaded', - function () { + () => { clone.pause(); Events.emit('entityclone', clone); - AFRAME.INSPECTOR.selectEntity(clone); + this.editor.selectEntity(clone); }, { once: true } ); @@ -58,4 +68,26 @@ export class EntityCloneCommand extends Command { nextCommandCallback?.(entity); } } + + toJSON() { + const output = super.toJSON(this); + output.entityIdToClone = this.entityIdToClone; + output.definition = this.detachedClone + ? elementToObject(this.detachedClone) + : null; + output.entityId = this.entityId; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.entityIdToClone = json.entityIdToClone; + if (json.definition) { + this.detachedClone = objectToElement(json.definition); + this.detachedClone.flushToDOM(); + } else { + this.detachedClone = null; + } + this.entityId = json.entityId; + } } diff --git a/src/lib/commands/EntityCreateCommand.js b/src/lib/commands/EntityCreateCommand.js index 00b08e27..d66252de 100644 --- a/src/lib/commands/EntityCreateCommand.js +++ b/src/lib/commands/EntityCreateCommand.js @@ -10,26 +10,28 @@ import { createEntity, createUniqueId } from '../entity.js'; * @constructor */ export class EntityCreateCommand extends Command { - constructor(editor, definition, callback = undefined) { + constructor(editor, definition = null, callback = undefined) { super(editor); this.type = 'entitycreate'; this.name = 'Create Entity'; - this.definition = definition; this.callback = callback; - this.entityId = null; - // If we have parentEl in the definition, be sure it has an id and store the definition with the id - if ( - this.definition.parentEl && - typeof this.definition.parentEl !== 'string' - ) { - if (!this.definition.parentEl.id) { - this.definition.parentEl.id = createUniqueId(); + if (definition !== null) { + this.definition = definition; + this.entityId = definition.id ?? null; + // If we have parentEl in the definition, be sure it has an id and store the definition with the id + if ( + this.definition.parentEl && + typeof this.definition.parentEl !== 'string' + ) { + if (!this.definition.parentEl.id) { + this.definition.parentEl.id = createUniqueId(); + } + this.definition = { + ...this.definition, + parentEl: this.definition.parentEl.id + }; } - this.definition = { - ...this.definition, - parentEl: this.definition.parentEl.id - }; } } @@ -43,7 +45,9 @@ export class EntityCreateCommand extends Command { }; let parentEl; if (this.definition.parentEl) { - parentEl = document.getElementById(this.definition.parentEl); + parentEl = document.querySelector( + `#${this.definition.parentEl}:not(a-mixin)` + ); } if (!parentEl) { parentEl = document.querySelector(this.editor.config.defaultParent); @@ -67,4 +71,17 @@ export class EntityCreateCommand extends Command { nextCommandCallback?.(entity); } } + + toJSON() { + const output = super.toJSON(this); + output.definition = this.definition; + output.entityId = this.entityId; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.definition = json.definition; + this.entityId = json.entityId; + } } diff --git a/src/lib/commands/EntityRemoveCommand.js b/src/lib/commands/EntityRemoveCommand.js index ee2467a6..bea66a9c 100644 --- a/src/lib/commands/EntityRemoveCommand.js +++ b/src/lib/commands/EntityRemoveCommand.js @@ -2,18 +2,38 @@ import Events from '../Events'; import { Command } from '../command.js'; import { findClosestEntity, prepareForSerialization } from '../entity.js'; +/** + * Command to remove an entity from the scene + * @param editor Editor + * @param entity Entity element or ID string + * @constructor + */ export class EntityRemoveCommand extends Command { - constructor(editor, entity) { + constructor(editor, entity = null) { super(editor); this.type = 'entityremove'; this.name = 'Remove Entity'; this.updatable = false; - this.entity = entity; - // Store the parent element and index for precise reinsertion - this.parentEl = entity.parentNode; - this.index = Array.from(this.parentEl.children).indexOf(entity); + if (entity !== null) { + // Handle case where entity is passed as ID string when used with multi command + if (typeof entity === 'string') { + this.entity = document.querySelector(`#${entity}:not(a-mixin)`); + if (!this.entity) { + console.error('Entity not found with ID:', entity); + return; + } + this.entityId = entity; + } else { + this.entity = entity; + this.entityId = entity.id; + } + + // Store the parent element and index for precise reinsertion + this.parentEl = this.entity.parentNode; + this.index = Array.from(this.parentEl.children).indexOf(this.entity); + } } execute(nextCommandCallback) { @@ -51,4 +71,20 @@ export class EntityRemoveCommand extends Command { { once: true } ); } + + toJSON() { + const output = super.toJSON(this); + output.entityId = this.entity.id; + output.parentId = this.parentEl.id; + output.index = this.index; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.entity = document.querySelector(`#${json.entityId}:not(a-mixin)`); + this.entityId = json.entityId; + this.parentEl = document.querySelector(`#${json.parentId}:not(a-mixin)`); + this.index = json.index; + } } diff --git a/src/lib/commands/EntityReparentCommand.js b/src/lib/commands/EntityReparentCommand.js index 5b6e1072..821f8f70 100644 --- a/src/lib/commands/EntityReparentCommand.js +++ b/src/lib/commands/EntityReparentCommand.js @@ -83,10 +83,12 @@ export class EntityReparentCommand extends Command { } execute(nextCommandCallback) { - const entity = document.getElementById(this.entityId); + const entity = document.querySelector(`#${this.entityId}:not(a-mixin)`); if (!entity) return; - const newParent = document.getElementById(this.newParentEl); + const newParent = document.querySelector( + `#${this.newParentEl}:not(a-mixin)` + ); if (!newParent) { console.error(`Parent element with id ${this.newParentEl} not found`); return; @@ -135,14 +137,14 @@ export class EntityReparentCommand extends Command { } undo(nextCommandCallback) { - const entity = document.getElementById(this.entityId); + const entity = document.querySelector(`#${this.entityId}:not(a-mixin)`); if (!entity) return; let oldParent; if (this.oldParentEl === 'a-scene') { oldParent = document.querySelector('a-scene'); } else if (this.oldParentEl) { - oldParent = document.getElementById(this.oldParentEl); + oldParent = document.querySelector(`#${this.oldParentEl}:not(a-mixin)`); } if (!oldParent) { console.error( @@ -192,4 +194,31 @@ export class EntityReparentCommand extends Command { return recreatedEntity; } + + toJSON() { + const output = super.toJSON(this); + output.entityId = this.entityId; + output.newParentEl = this.newParentEl; + output.newIndexInParent = this.newIndexInParent; + output.oldParentEl = this.oldParentEl; + output.oldIndexInParent = this.oldIndexInParent; + output.entityData = this.entityData; + output.worldPosition = this.worldPosition.toArray(); + output.worldQuaternion = this.worldQuaternion.toArray(); + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.entityId = json.entityId; + this.newParentEl = json.newParentEl; + this.newIndexInParent = json.newIndexInParent; + this.oldParentEl = json.oldParentEl; + this.oldIndexInParent = json.oldIndexInParent; + this.entityData = json.entityData; + this.worldPosition = new THREE.Vector3().fromArray(json.worldPosition); + this.worldQuaternion = new THREE.Quaternion().fromArray( + json.worldQuaternion + ); + } } diff --git a/src/lib/commands/EntityUpdateCommand.js b/src/lib/commands/EntityUpdateCommand.js index 263d6c02..7b0b0549 100644 --- a/src/lib/commands/EntityUpdateCommand.js +++ b/src/lib/commands/EntityUpdateCommand.js @@ -3,22 +3,35 @@ import { createUniqueId, updateEntity } from '../entity.js'; /** * @param editor Editor - * @param payload: entity, component, property, value. + * @param payload: entity (element or ID string), component, property, value. * @constructor */ export class EntityUpdateCommand extends Command { - constructor(editor, payload) { + constructor(editor, payload = null) { super(editor); this.type = 'entityupdate'; this.name = 'Update Entity'; this.updatable = true; - const entity = payload.entity; - if (!entity.id) { - entity.id = createUniqueId(); + if (payload === null) return; + + // Handle case where entity is passed as ID string when used with multi command + let entity; + if (typeof payload.entity === 'string') { + entity = document.querySelector(`#${payload.entity}:not(a-mixin)`); + if (!entity) { + console.error('Entity not found with ID:', payload.entity); + return; + } + this.entityId = payload.entity; + } else { + entity = payload.entity; + if (!entity.id) { + entity.id = createUniqueId(); + } + this.entityId = entity.id; } - this.entityId = entity.id; this.component = payload.component; this.property = payload.property ?? ''; @@ -36,12 +49,12 @@ export class EntityUpdateCommand extends Command { payload.value ); this.oldValue = component.schema[payload.property].stringify( - payload.entity.getAttribute(payload.component)[payload.property] + entity.getAttribute(payload.component)[payload.property] ); } else { // Just in case dynamic schema is not properly updated and we set an unknown property. I don't think this should happen. this.newValue = payload.value; - this.oldValue = payload.entity.getAttribute(payload.component)[ + this.oldValue = entity.getAttribute(payload.component)[ payload.property ]; } @@ -59,10 +72,8 @@ export class EntityUpdateCommand extends Command { ? component.schema.stringify(payload.value) : payload.value; this.oldValue = component.isSingleProperty - ? component.schema.stringify( - payload.entity.getAttribute(payload.component) - ) - : structuredClone(payload.entity.getDOMAttribute(payload.component)); + ? component.schema.stringify(entity.getAttribute(payload.component)) + : structuredClone(entity.getDOMAttribute(payload.component)); if (this.editor.config.debugUndoRedo) { console.log( 'entityupdate component', @@ -75,7 +86,7 @@ export class EntityUpdateCommand extends Command { } else { // id, class, mixin, data attributes this.newValue = payload.value; - this.oldValue = payload.entity.getAttribute(this.component); + this.oldValue = entity.getAttribute(this.component); if (this.editor.config.debugUndoRedo) { console.log( 'entityupdate attribute', @@ -142,4 +153,23 @@ export class EntityUpdateCommand extends 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; + } } diff --git a/src/lib/commands/MultiCommand.js b/src/lib/commands/MultiCommand.js index 627d4e8b..6867bd91 100644 --- a/src/lib/commands/MultiCommand.js +++ b/src/lib/commands/MultiCommand.js @@ -9,21 +9,29 @@ import { commandsByType } from './index.js'; * @constructor */ export class MultiCommand extends Command { - constructor(editor, commands, callback = undefined) { + constructor(editor, jsonCommands = null, callback = undefined) { super(editor); this.type = 'multi'; this.name = 'Multiple changes'; this.updatable = false; this.callback = callback; - this.commands = commands + if (jsonCommands !== null) { + this.jsonCommands = jsonCommands; + this.commands = this.createCommands(jsonCommands); + } + } + + createCommands(jsonCommands) { + return jsonCommands .map((cmdTuple) => { const Cmd = commandsByType.get(cmdTuple[0]); if (!Cmd) { console.error(`Command ${cmdTuple[0]} not found`); return null; } - return new Cmd(editor, cmdTuple[1], cmdTuple[2]); + const command = new Cmd(this.editor, cmdTuple[1], cmdTuple[2]); + return command; }) .filter(Boolean); } @@ -47,4 +55,15 @@ export class MultiCommand extends Command { }, this.callback); // latest callback uses the entity as parameter return run(); } + + toJSON() { + const output = super.toJSON(this); + output.commands = this.jsonCommands; + return output; + } + + fromJSON(json) { + super.fromJSON(json); + this.commands = this.createCommands(json.commands); + } } diff --git a/src/lib/history.js b/src/lib/history.js index 80654854..19454d07 100644 --- a/src/lib/history.js +++ b/src/lib/history.js @@ -1,3 +1,4 @@ +import { commandsByType } from './commands'; import Events from './Events'; export class History { @@ -40,6 +41,9 @@ export class History { cmd.name = optionalName !== undefined ? optionalName : cmd.name; cmd.execute(); + cmd.inMemory = true; + + cmd.json = cmd.toJSON(); // serialize the cmd immediately after execution and append the json to the cmd this.lastCmdTime = Date.now(); @@ -59,10 +63,15 @@ export class History { if (this.undos.length > 0) { cmd = this.undos.pop(); + + if (cmd.inMemory === false) { + cmd.fromJSON(cmd.json); + } } if (cmd !== undefined) { cmd.undo(); + cmd.json = cmd.toJSON(); this.redos.push(cmd); Events.emit('historychanged', cmd); } @@ -80,10 +89,15 @@ export class History { if (this.redos.length > 0) { cmd = this.redos.pop(); + + if (cmd.inMemory === false) { + cmd.fromJSON(cmd.json); + } } if (cmd !== undefined) { cmd.execute(); + cmd.json = cmd.toJSON(); this.undos.push(cmd); Events.emit('historychanged', cmd); } @@ -91,6 +105,53 @@ export class History { return cmd; } + toJSON() { + const history = {}; + history.undos = []; + history.redos = []; + + // Append Undos to History + for (let i = 0; i < this.undos.length; i++) { + if (this.undos[i].hasOwnProperty('json')) { + history.undos.push(this.undos[i].json); + } + } + + // Append Redos to History + for (let i = 0; i < this.redos.length; i++) { + if (this.redos[i].hasOwnProperty('json')) { + history.redos.push(this.redos[i].json); + } + } + + return history; + } + + fromJSON(json) { + if (json === undefined) return; + + for (let i = 0; i < json.undos.length; i++) { + const cmdJSON = json.undos[i]; + const cmd = new commandsByType.get(cmdJSON.type)(this.editor); // creates a new object of type "json.type" + cmd.json = cmdJSON; + cmd.id = cmdJSON.id; + cmd.name = cmdJSON.name; + this.undos.push(cmd); + } + + for (let i = 0; i < json.redos.length; i++) { + const cmdJSON = json.redos[i]; + const cmd = new commandsByType.get[cmdJSON.type](this.editor); // creates a new object of type "json.type" + cmd.json = cmdJSON; + cmd.id = cmdJSON.id; + cmd.name = cmdJSON.name; + this.redos.push(cmd); + } + + // Select the last executed undo-command + Events.emit('historychanged', this.undos[this.undos.length - 1]); + } + clear() { this.undos = []; this.redos = []; From 2ff2da72018580249653eea0d5c99c2aade34f8e Mon Sep 17 00:00:00 2001 From: Vincent Fretin Date: Sun, 22 Feb 2026 10:07:15 +0100 Subject: [PATCH 2/2] fix lint --- src/lib/history.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/history.js b/src/lib/history.js index 19454d07..073560da 100644 --- a/src/lib/history.js +++ b/src/lib/history.js @@ -1,3 +1,4 @@ +/* eslint-disable no-prototype-builtins, new-cap */ import { commandsByType } from './commands'; import Events from './Events';