diff --git a/core/events/events_block_change.js b/core/events/events_block_change.js
index 3814a0d27..96afcd862 100644
--- a/core/events/events_block_change.js
+++ b/core/events/events_block_change.js
@@ -15,6 +15,8 @@ goog.module.declareLegacyNamespace();
/* eslint-disable-next-line no-unused-vars */
const Block = goog.requireType('Blockly.Block');
+/* eslint-disable-next-line no-unused-vars */
+const BlockSvg = goog.requireType('Blockly.BlockSvg');
const Events = goog.require('Blockly.Events');
const Xml = goog.require('Blockly.Xml');
const object = goog.require('Blockly.utils.object');
@@ -125,17 +127,15 @@ BlockChange.prototype.run = function(forward) {
block.setInputsInline(!!value);
break;
case 'mutation': {
- let oldMutation = '';
- if (block.mutationToDom) {
- const oldMutationDom = block.mutationToDom();
- oldMutation = oldMutationDom && Xml.domToText(oldMutationDom);
+ const oldState = BlockChange.getExtraBlockState_(
+ /** @type {!BlockSvg} */ (block));
+ if (block.loadExtraState) {
+ block.loadExtraState(JSON.parse(/** @type {string} */ (value) || '{}'));
+ } else if (block.domToMutation) {
+ block.domToMutation(
+ Xml.textToDom(/** @type {string} */ (value) || ''));
}
- if (block.domToMutation) {
- const dom = Xml.textToDom(/** @type {string} */
- (value) || '');
- block.domToMutation(dom);
- }
- Events.fire(new BlockChange(block, 'mutation', null, oldMutation, value));
+ Events.fire(new BlockChange(block, 'mutation', null, oldState, value));
break;
}
default:
@@ -143,6 +143,26 @@ BlockChange.prototype.run = function(forward) {
}
};
+// TODO (#5397): Encapsulate this in the BlocklyMutationChange event when
+// refactoring change events.
+/**
+ * Returns the extra state of the given block (either as XML or a JSO, depending
+ * on the block's definition).
+ * @param {!BlockSvg} block The block to get the extra state of.
+ * @return {string} A stringified version of the extra state of the given block.
+ * @package
+ */
+BlockChange.getExtraBlockState_ = function(block) {
+ if (block.saveExtraState) {
+ const state = block.saveExtraState();
+ return state ? JSON.stringify(state) : '';
+ } else if (block.mutationToDom) {
+ const state = block.mutationToDom();
+ return state ? Xml.domToText(state) : '';
+ }
+ return '';
+};
+
registry.register(registry.Type.EVENT, Events.CHANGE, BlockChange);
exports = BlockChange;
diff --git a/core/field.js b/core/field.js
index 041975150..fbc4ff341 100644
--- a/core/field.js
+++ b/core/field.js
@@ -897,11 +897,11 @@ Field.prototype.setValue = function(newValue) {
return;
}
+ this.doValueUpdate_(newValue);
if (source && Events.isEnabled()) {
Events.fire(new (Events.get(Events.BLOCK_CHANGE))(
source, 'field', this.name || null, oldValue, newValue));
}
- this.doValueUpdate_(newValue);
if (this.isDirty_) {
this.forceRerender();
}
diff --git a/core/insertion_marker_manager.js b/core/insertion_marker_manager.js
index b44097416..004ac569e 100644
--- a/core/insertion_marker_manager.js
+++ b/core/insertion_marker_manager.js
@@ -289,7 +289,12 @@ InsertionMarkerManager.prototype.createMarkerBlock_ = function(sourceBlock) {
try {
result = this.workspace_.newBlock(imType);
result.setInsertionMarker(true);
- if (sourceBlock.mutationToDom) {
+ if (sourceBlock.saveExtraState) {
+ const state = sourceBlock.saveExtraState();
+ if (state) {
+ result.loadExtraState(state);
+ }
+ } else if (sourceBlock.mutationToDom) {
const oldMutationDom = sourceBlock.mutationToDom();
if (oldMutationDom) {
result.domToMutation(oldMutationDom);
diff --git a/core/mutator.js b/core/mutator.js
index 5f7189fcd..e3b5fd9d8 100644
--- a/core/mutator.js
+++ b/core/mutator.js
@@ -34,7 +34,6 @@ const Svg = goog.require('Blockly.utils.Svg');
/* eslint-disable-next-line no-unused-vars */
const Workspace = goog.requireType('Blockly.Workspace');
const WorkspaceSvg = goog.require('Blockly.WorkspaceSvg');
-const Xml = goog.require('Blockly.Xml');
const dom = goog.require('Blockly.utils.dom');
const internalConstants = goog.require('Blockly.internalConstants');
const object = goog.require('Blockly.utils.object');
@@ -408,9 +407,8 @@ Mutator.prototype.workspaceChanged_ = function(e) {
// When the mutator's workspace changes, update the source block.
if (this.rootBlock_.workspace == this.workspace_) {
Events.setGroup(true);
- const block = this.block_;
- const oldMutationDom = block.mutationToDom();
- const oldMutation = oldMutationDom && Xml.domToText(oldMutationDom);
+ const block = /** @type {!BlockSvg} */ (this.block_);
+ const oldExtraState = Events.BlockChange.getExtraBlockState_(block);
// Switch off rendering while the source block is rebuilt.
const savedRendered = block.rendered;
@@ -428,11 +426,10 @@ Mutator.prototype.workspaceChanged_ = function(e) {
block.render();
}
- const newMutationDom = block.mutationToDom();
- const newMutation = newMutationDom && Xml.domToText(newMutationDom);
- if (oldMutation != newMutation) {
+ const newExtraState = Events.BlockChange.getExtraBlockState_(block);
+ if (oldExtraState != newExtraState) {
Events.fire(new (Events.get(Events.BLOCK_CHANGE))(
- block, 'mutation', null, oldMutation, newMutation));
+ block, 'mutation', null, oldExtraState, newExtraState));
// Ensure that any bump is part of this mutation's event group.
const group = Events.getGroup();
setTimeout(function() {
diff --git a/tests/deps.js b/tests/deps.js
index 32bf81b39..8850112c2 100644
--- a/tests/deps.js
+++ b/tests/deps.js
@@ -130,7 +130,7 @@ goog.addDependency('../../core/menu.js', ['Blockly.Menu'], ['Blockly.browserEven
goog.addDependency('../../core/menuitem.js', ['Blockly.MenuItem'], ['Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.idGenerator'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/metrics_manager.js', ['Blockly.MetricsManager'], ['Blockly.registry', 'Blockly.utils.Size', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/msg.js', ['Blockly.Msg'], ['Blockly.utils.global'], {'lang': 'es6', 'module': 'goog'});
-goog.addDependency('../../core/mutator.js', ['Blockly.Mutator'], ['Blockly.Bubble', 'Blockly.Events', 'Blockly.Events.BlockChange', 'Blockly.Events.BubbleOpen', 'Blockly.Icon', 'Blockly.Options', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.internalConstants', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.toolbox', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'});
+goog.addDependency('../../core/mutator.js', ['Blockly.Mutator'], ['Blockly.Bubble', 'Blockly.Events', 'Blockly.Events.BlockChange', 'Blockly.Events.BubbleOpen', 'Blockly.Icon', 'Blockly.Options', 'Blockly.WorkspaceSvg', 'Blockly.internalConstants', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.toolbox', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/names.js', ['Blockly.Names'], ['Blockly.Msg', 'Blockly.Variables', 'Blockly.internalConstants'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/options.js', ['Blockly.Options'], ['Blockly.Theme', 'Blockly.Themes.Classic', 'Blockly.registry', 'Blockly.utils.deprecation', 'Blockly.utils.idGenerator', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/positionable_helpers.js', ['Blockly.uiPosition'], ['Blockly.Scrollbar', 'Blockly.utils.Rect', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
diff --git a/tests/deps.mocha.js b/tests/deps.mocha.js
index 85364266c..c89219406 100644
--- a/tests/deps.mocha.js
+++ b/tests/deps.mocha.js
@@ -130,7 +130,7 @@ goog.addDependency('../../core/menu.js', ['Blockly.Menu'], ['Blockly.browserEven
goog.addDependency('../../core/menuitem.js', ['Blockly.MenuItem'], ['Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.idGenerator'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/metrics_manager.js', ['Blockly.MetricsManager'], ['Blockly.registry', 'Blockly.utils.Size', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/msg.js', ['Blockly.Msg'], ['Blockly.utils.global'], {'lang': 'es6', 'module': 'goog'});
-goog.addDependency('../../core/mutator.js', ['Blockly.Mutator'], ['Blockly.Bubble', 'Blockly.Events', 'Blockly.Events.BlockChange', 'Blockly.Events.BubbleOpen', 'Blockly.Icon', 'Blockly.Options', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.internalConstants', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.toolbox', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'});
+goog.addDependency('../../core/mutator.js', ['Blockly.Mutator'], ['Blockly.Bubble', 'Blockly.Events', 'Blockly.Events.BlockChange', 'Blockly.Events.BubbleOpen', 'Blockly.Icon', 'Blockly.Options', 'Blockly.WorkspaceSvg', 'Blockly.internalConstants', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.toolbox', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/names.js', ['Blockly.Names'], ['Blockly.Msg', 'Blockly.Variables', 'Blockly.internalConstants'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/options.js', ['Blockly.Options'], ['Blockly.Theme', 'Blockly.Themes.Classic', 'Blockly.registry', 'Blockly.utils.deprecation', 'Blockly.utils.idGenerator', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../core/positionable_helpers.js', ['Blockly.uiPosition'], ['Blockly.Scrollbar', 'Blockly.utils.Rect', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'});
@@ -303,6 +303,7 @@ goog.addDependency('../../generators/python/variables.js', ['Blockly.Python.vari
goog.addDependency('../../generators/python/variables_dynamic.js', ['Blockly.Python.variablesDynamic'], ['Blockly.Python', 'Blockly.Python.variables']);
goog.addDependency('../../tests/mocha/.mocharc.js', [], []);
goog.addDependency('../../tests/mocha/astnode_test.js', ['Blockly.test.astNode'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
+goog.addDependency('../../tests/mocha/block_change_event_test.js', ['Blockly.test.blockChangeEvent'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/block_json_test.js', ['Blockly.test.blockJson'], [], {'lang': 'es5', 'module': 'goog'});
goog.addDependency('../../tests/mocha/block_test.js', ['Blockly.test.blocks'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/comment_test.js', ['Blockly.test.comments'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
@@ -338,6 +339,7 @@ goog.addDependency('../../tests/mocha/json_test.js', ['Blockly.test.json'], ['Bl
goog.addDependency('../../tests/mocha/keydown_test.js', ['Blockly.test.keydown'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/logic_ternary_test.js', ['Blockly.test.logicTernary'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/metrics_test.js', ['Blockly.test.metrics'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
+goog.addDependency('../../tests/mocha/mutator_test.js', ['Blockly.test.mutator'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/names_test.js', ['Blockly.test.names'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/procedures_test.js', ['Blockly.test.procedures'], ['Blockly.Blocks.procedures', 'Blockly.Msg', 'Blockly.test.helpers', 'Blockly.test.procedureHelpers'], {'lang': 'es6', 'module': 'goog'});
goog.addDependency('../../tests/mocha/procedures_test_helpers.js', ['Blockly.test.procedureHelpers'], [], {'lang': 'es6', 'module': 'goog'});
diff --git a/tests/mocha/block_change_event_test.js b/tests/mocha/block_change_event_test.js
new file mode 100644
index 000000000..d29e01daa
--- /dev/null
+++ b/tests/mocha/block_change_event_test.js
@@ -0,0 +1,74 @@
+
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+goog.module('Blockly.test.blockChangeEvent');
+
+const {defineMutatorBlocks, sharedTestSetup, sharedTestTeardown} = goog.require('Blockly.test.helpers');
+
+
+suite('Block Change Event', function() {
+ setup(function() {
+ sharedTestSetup.call(this);
+ this.workspace = new Blockly.Workspace();
+ });
+
+ teardown(function() {
+ sharedTestTeardown.call(this);
+ });
+
+ suite('Undo and Redo', function() {
+ suite('Mutation', function() {
+ setup(function() {
+ defineMutatorBlocks();
+ });
+
+ teardown(function() {
+ Blockly.Extensions.unregister('xml_mutator');
+ Blockly.Extensions.unregister('jso_mutator');
+ });
+
+ suite('XML', function() {
+ test('Undo', function() {
+ const block = this.workspace.newBlock('xml_block', 'block_id');
+ block.domToMutation(
+ Blockly.Xml.textToDom(''));
+ const blockChange = new Blockly.Events.BlockChange(
+ block, 'mutation', null, '', '');
+ blockChange.run(false);
+ chai.assert.isFalse(block.hasInput);
+ });
+
+ test('Redo', function() {
+ const block = this.workspace.newBlock('xml_block', 'block_id');
+ const blockChange = new Blockly.Events.BlockChange(
+ block, 'mutation', null, '', '');
+ blockChange.run(true);
+ chai.assert.isTrue(block.hasInput);
+ });
+ });
+
+ suite('JSO', function() {
+ test('Undo', function() {
+ const block = this.workspace.newBlock('jso_block', 'block_id');
+ block.loadExtraState({hasInput: true});
+ const blockChange = new Blockly.Events.BlockChange(
+ block, 'mutation', null, '', '{"hasInput":true}');
+ blockChange.run(false);
+ chai.assert.isFalse(block.hasInput);
+ });
+
+ test('Redo', function() {
+ const block = this.workspace.newBlock('jso_block', 'block_id');
+ const blockChange = new Blockly.Events.BlockChange(
+ block, 'mutation', null, '', '{"hasInput":true}');
+ blockChange.run(true);
+ chai.assert.isTrue(block.hasInput);
+ });
+ });
+ });
+ });
+});
diff --git a/tests/mocha/index.html b/tests/mocha/index.html
index 9b28c2422..0500b3581 100644
--- a/tests/mocha/index.html
+++ b/tests/mocha/index.html
@@ -53,6 +53,7 @@