diff --git a/core/requires.js b/core/requires.js index cc67f564a..fc6575fd5 100644 --- a/core/requires.js +++ b/core/requires.js @@ -81,3 +81,5 @@ goog.require('Blockly.zelos.Renderer'); // Blockly Themes. // Classic is the default theme. goog.require('Blockly.Themes.Classic'); + +goog.require('Blockly.serialization.blocks'); diff --git a/core/serialization/blocks.js b/core/serialization/blocks.js new file mode 100644 index 000000000..cefdb3317 --- /dev/null +++ b/core/serialization/blocks.js @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Handles serializing blocks to plain JavaScript objects only + * containing state. + */ +'use strict'; + +goog.module('Blockly.serialization.blocks'); +goog.module.declareLegacyNamespace(); + + +// TODO: Remove this once lint is fixed. +/* eslint-disable no-use-before-define */ + +/** + * Represents the state of a given block. + * @typedef {{ + * type: string, + * id: string, + * x: (number|undefined), + * y: (number|undefined), + * collapsed: (boolean|undefined), + * disabled: (boolean|undefined), + * editable: (boolean|undefined), + * deletable: (boolean|undefined), + * movable: (boolean|undefined), + * inline: (boolean|undefined), + * data: (string|undefined), + * }} + */ +var State; +exports.State = State; + +/** + * Returns the state of the given block as a plain JavaScript object. + * @param {!Blockly.Block} block The block to serialize. + * @param {{addCoordinates: (boolean|undefined)}=} param1 + * addCoordinates: If true the coordinates of the block are added to the + * serialized state. False by default. + * @return {?State} The serialized state of the + * block, or null if the block could not be serialied (eg it was an + * insertion marker). + */ +const save = function(block, {addCoordinates = false} = {}) { + if (block.isInsertionMarker()) { + return null; + } + + const state = { + 'type': block.type, + 'id': block.id + }; + + if (addCoordinates) { + addCoords(block, state); + } + addAttributes(block, state); + + return state; +}; +exports.save = save; + +/** + * Adds attributes to the given state object based on the state of the block. + * Eg collapsed, disabled, editable, etc. + * @param {!Blockly.Block} block The block to base the attributes on. + * @param {!State} state The state object to append + * to. + */ +const addAttributes = function(block, state) { + if (block.isCollapsed()) { + state['collapsed'] = true; + } + if (!block.isEnabled()) { + state['enabled'] = false; + } + if (!block.isEditable()) { + state['editable'] = false; + } + if (!block.isDeletable() && !block.isShadow()) { + state['deletable'] = false; + } + if (!block.isMovable() && !block.isShadow()) { + state['movable'] = false; + } + + if (block.inputsInline !== undefined && + block.inputsInline !== block.inputsInlineDefault) { + state['inline'] = block.inputsInline; + } + + // Data is a nullable string, so we don't need to worry about falsy values. + if (block.data) { + state['data'] = block.data; + } +}; + +/** + * Adds the coordinates of the given block to the given state object. + * @param {!Blockly.Block} block The block to base the coordinates on + * @param {!State} state The state object to append + * to + */ +const addCoords = function(block, state) { + const workspace = block.workspace; + const xy = block.getRelativeToSurfaceXY(); + state['x'] = Math.round(workspace.RTL ? workspace.getWidth() - xy.x : xy.x); + state['y'] = Math.round(xy.y); +}; diff --git a/core/serialization/serialization.js b/core/serialization/serialization.js new file mode 100644 index 000000000..c53a2eb64 --- /dev/null +++ b/core/serialization/serialization.js @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Contains top-level functions for serialization of the workspace + * to JavaScript objects. + */ +'use strict'; + +/** + * The top level namespace for JavaScript Object serialization. + * @namespace Blockly.serialization + */ +goog.module('Blockly.serialization'); +goog.module.declareLegacyNamespace(); diff --git a/tests/deps.js b/tests/deps.js index 64284f839..bf85399f8 100644 --- a/tests/deps.js +++ b/tests/deps.js @@ -193,9 +193,11 @@ goog.addDependency('../../core/renderers/zelos/measurables/row_elements.js', ['B goog.addDependency('../../core/renderers/zelos/measurables/top_row.js', ['Blockly.zelos.TopRow'], ['Blockly.blockRendering.TopRow', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/renderers/zelos/path_object.js', ['Blockly.zelos.PathObject'], ['Blockly.blockRendering.PathObject', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/renderers/zelos/renderer.js', ['Blockly.zelos.Renderer'], ['Blockly.InsertionMarkerManager', 'Blockly.blockRendering', 'Blockly.blockRendering.Renderer', 'Blockly.connectionTypes', 'Blockly.utils.object', 'Blockly.zelos.ConstantProvider', 'Blockly.zelos.Drawer', 'Blockly.zelos.MarkerSvg', 'Blockly.zelos.PathObject', 'Blockly.zelos.RenderInfo'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/requires.js', ['Blockly.requires'], ['Blockly', 'Blockly.Comment', 'Blockly.ContextMenuItems', 'Blockly.FieldAngle', 'Blockly.FieldCheckbox', 'Blockly.FieldColour', 'Blockly.FieldDropdown', 'Blockly.FieldImage', 'Blockly.FieldLabelSerializable', 'Blockly.FieldMultilineInput', 'Blockly.FieldNumber', 'Blockly.FieldTextInput', 'Blockly.FieldVariable', 'Blockly.FlyoutButton', 'Blockly.Generator', 'Blockly.HorizontalFlyout', 'Blockly.Mutator', 'Blockly.ShortcutItems', 'Blockly.Themes.Classic', 'Blockly.Toolbox', 'Blockly.Trashcan', 'Blockly.VariablesDynamic', 'Blockly.VerticalFlyout', 'Blockly.Warning', 'Blockly.ZoomControls', 'Blockly.geras.Renderer', 'Blockly.thrasos.Renderer', 'Blockly.zelos.Renderer']); +goog.addDependency('../../core/requires.js', ['Blockly.requires'], ['Blockly', 'Blockly.Comment', 'Blockly.ContextMenuItems', 'Blockly.FieldAngle', 'Blockly.FieldCheckbox', 'Blockly.FieldColour', 'Blockly.FieldDropdown', 'Blockly.FieldImage', 'Blockly.FieldLabelSerializable', 'Blockly.FieldMultilineInput', 'Blockly.FieldNumber', 'Blockly.FieldTextInput', 'Blockly.FieldVariable', 'Blockly.FlyoutButton', 'Blockly.Generator', 'Blockly.HorizontalFlyout', 'Blockly.Mutator', 'Blockly.ShortcutItems', 'Blockly.Themes.Classic', 'Blockly.Toolbox', 'Blockly.Trashcan', 'Blockly.VariablesDynamic', 'Blockly.VerticalFlyout', 'Blockly.Warning', 'Blockly.ZoomControls', 'Blockly.geras.Renderer', 'Blockly.serialization.blocks', 'Blockly.thrasos.Renderer', 'Blockly.zelos.Renderer']); goog.addDependency('../../core/scrollbar.js', ['Blockly.Scrollbar'], ['Blockly.Touch', 'Blockly.browserEvents', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/scrollbar_pair.js', ['Blockly.ScrollbarPair'], ['Blockly.Events', 'Blockly.Scrollbar', 'Blockly.utils.Svg', 'Blockly.utils.dom'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/serialization/blocks.js', ['Blockly.serialization.blocks'], [], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/serialization/serialization.js', ['Blockly.serialization'], [], {'module': 'goog'}); goog.addDependency('../../core/shortcut_items.js', ['Blockly.ShortcutItems'], ['Blockly.Gesture', 'Blockly.ShortcutRegistry', 'Blockly.clipboard', 'Blockly.common', 'Blockly.utils.KeyCodes'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/shortcut_registry.js', ['Blockly.ShortcutRegistry'], ['Blockly.utils.KeyCodes', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/theme.js', ['Blockly.Theme'], ['Blockly.registry', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); diff --git a/tests/deps.mocha.js b/tests/deps.mocha.js index 85a06ac1a..d0b49e0a5 100644 --- a/tests/deps.mocha.js +++ b/tests/deps.mocha.js @@ -193,9 +193,11 @@ goog.addDependency('../../core/renderers/zelos/measurables/row_elements.js', ['B goog.addDependency('../../core/renderers/zelos/measurables/top_row.js', ['Blockly.zelos.TopRow'], ['Blockly.blockRendering.TopRow', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/renderers/zelos/path_object.js', ['Blockly.zelos.PathObject'], ['Blockly.blockRendering.PathObject', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/renderers/zelos/renderer.js', ['Blockly.zelos.Renderer'], ['Blockly.InsertionMarkerManager', 'Blockly.blockRendering', 'Blockly.blockRendering.Renderer', 'Blockly.connectionTypes', 'Blockly.utils.object', 'Blockly.zelos.ConstantProvider', 'Blockly.zelos.Drawer', 'Blockly.zelos.MarkerSvg', 'Blockly.zelos.PathObject', 'Blockly.zelos.RenderInfo'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/requires.js', ['Blockly.requires'], ['Blockly', 'Blockly.Comment', 'Blockly.ContextMenuItems', 'Blockly.FieldAngle', 'Blockly.FieldCheckbox', 'Blockly.FieldColour', 'Blockly.FieldDropdown', 'Blockly.FieldImage', 'Blockly.FieldLabelSerializable', 'Blockly.FieldMultilineInput', 'Blockly.FieldNumber', 'Blockly.FieldTextInput', 'Blockly.FieldVariable', 'Blockly.FlyoutButton', 'Blockly.Generator', 'Blockly.HorizontalFlyout', 'Blockly.Mutator', 'Blockly.ShortcutItems', 'Blockly.Themes.Classic', 'Blockly.Toolbox', 'Blockly.Trashcan', 'Blockly.VariablesDynamic', 'Blockly.VerticalFlyout', 'Blockly.Warning', 'Blockly.ZoomControls', 'Blockly.geras.Renderer', 'Blockly.thrasos.Renderer', 'Blockly.zelos.Renderer']); +goog.addDependency('../../core/requires.js', ['Blockly.requires'], ['Blockly', 'Blockly.Comment', 'Blockly.ContextMenuItems', 'Blockly.FieldAngle', 'Blockly.FieldCheckbox', 'Blockly.FieldColour', 'Blockly.FieldDropdown', 'Blockly.FieldImage', 'Blockly.FieldLabelSerializable', 'Blockly.FieldMultilineInput', 'Blockly.FieldNumber', 'Blockly.FieldTextInput', 'Blockly.FieldVariable', 'Blockly.FlyoutButton', 'Blockly.Generator', 'Blockly.HorizontalFlyout', 'Blockly.Mutator', 'Blockly.ShortcutItems', 'Blockly.Themes.Classic', 'Blockly.Toolbox', 'Blockly.Trashcan', 'Blockly.VariablesDynamic', 'Blockly.VerticalFlyout', 'Blockly.Warning', 'Blockly.ZoomControls', 'Blockly.geras.Renderer', 'Blockly.serialization.blocks', 'Blockly.thrasos.Renderer', 'Blockly.zelos.Renderer']); goog.addDependency('../../core/scrollbar.js', ['Blockly.Scrollbar'], ['Blockly.Touch', 'Blockly.browserEvents', 'Blockly.utils', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/scrollbar_pair.js', ['Blockly.ScrollbarPair'], ['Blockly.Events', 'Blockly.Scrollbar', 'Blockly.utils.Svg', 'Blockly.utils.dom'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/serialization/blocks.js', ['Blockly.serialization.blocks'], [], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/serialization/serialization.js', ['Blockly.serialization'], [], {'module': 'goog'}); goog.addDependency('../../core/shortcut_items.js', ['Blockly.ShortcutItems'], ['Blockly.Gesture', 'Blockly.ShortcutRegistry', 'Blockly.clipboard', 'Blockly.common', 'Blockly.utils.KeyCodes'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/shortcut_registry.js', ['Blockly.ShortcutRegistry'], ['Blockly.utils.KeyCodes', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/theme.js', ['Blockly.Theme'], ['Blockly.registry', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); @@ -328,6 +330,7 @@ goog.addDependency('../../tests/mocha/generator_test.js', ['Blockly.test.generat goog.addDependency('../../tests/mocha/gesture_test.js', ['Blockly.test.gesture'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../tests/mocha/input_test.js', ['Blockly.test.input'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../tests/mocha/insertion_marker_test.js', ['Blockly.test.insertionMarker'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../tests/mocha/jso_serialization_test.js', ['Blockly.test.jsoSerialization'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../tests/mocha/json_test.js', ['Blockly.test.json'], ['Blockly.test.helpers'], {'lang': 'es6', 'module': 'goog'}); 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'}); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 81ba4476e..ab34e2c8e 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -82,6 +82,7 @@ goog.require('Blockly.test.gesture'); goog.require('Blockly.test.input'); goog.require('Blockly.test.insertionMarker'); + goog.require('Blockly.test.jsoSerialization'); goog.require('Blockly.test.json'); goog.require('Blockly.test.keydown'); goog.require('Blockly.test.logicTernary'); diff --git a/tests/mocha/jso_serialization_test.js b/tests/mocha/jso_serialization_test.js new file mode 100644 index 000000000..2b24d170c --- /dev/null +++ b/tests/mocha/jso_serialization_test.js @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.module('Blockly.test.jsoSerialization'); + +const {defineStackBlock, defineRowBlock, defineStatementBlock, createGenUidStubWithReturns, sharedTestSetup, sharedTestTeardown, workspaceTeardown} = goog.require('Blockly.test.helpers'); + + +suite('JSO', function() { + setup(function() { + sharedTestSetup.call(this); + this.workspace = new Blockly.Workspace(); + + defineStackBlock(); + defineRowBlock(); + defineStatementBlock(); + + createGenUidStubWithReturns(new Array(10).fill().map((_, i) => 'id' + i)); + }); + + teardown(function() { + workspaceTeardown.call(this, this.workspace); + sharedTestTeardown.call(this); + }); + + suite('Blocks', function() { + test('Null on insertionMarkers', function() { + const block = this.workspace.newBlock('row_block'); + block.setInsertionMarker(true); + const jso = Blockly.serialization.blocks.save(block); + chai.assert.isNull(jso); + }); + + suite('Save Single Block', function() { + + function assertProperty(obj, property, value) { + chai.assert.equal(obj[property], value); + } + + function assertNoProperty(obj, property) { + assertProperty(obj, property, undefined); + } + + test('Basic', function() { + const block = this.workspace.newBlock('row_block'); + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'type', 'row_block'); + assertProperty(jso, 'id', 'id0'); + }); + + suite('Attributes', function() { + suite('Collapsed', function() { + test('True', function() { + const block = this.workspace.newBlock('row_block'); + block.setCollapsed(true); + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'collapsed', true); + }); + + test('False', function() { + const block = this.workspace.newBlock('row_block'); + block.setCollapsed(false); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'collapsed'); + }); + }); + + suite('Enabled', function() { + test('False', function() { + const block = this.workspace.newBlock('row_block'); + block.setEnabled(false); + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'enabled', false); + }); + + test('True', function() { + const block = this.workspace.newBlock('row_block'); + block.setEnabled(true); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'enabled'); + }); + }); + + suite('Deletable', function() { + test('False', function() { + const block = this.workspace.newBlock('row_block'); + block.setDeletable(false); + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'deletable', false); + }); + + test('True', function() { + const block = this.workspace.newBlock('row_block'); + block.setDeletable(true); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'deletable'); + }); + + test('False and Shadow', function() { + const block = this.workspace.newBlock('row_block'); + block.setDeletable(false); + block.setShadow(true); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'deletable'); + }); + }); + + suite('Movable', function() { + test('False', function() { + const block = this.workspace.newBlock('row_block'); + block.setMovable(false); + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'movable', false); + }); + + test('True', function() { + const block = this.workspace.newBlock('row_block'); + block.setMovable(true); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'movable'); + }); + + test('False and Shadow', function() { + const block = this.workspace.newBlock('row_block'); + block.setMovable(false); + block.setShadow(true); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'movable'); + }); + }); + + suite('Editable', function() { + test('False', function() { + const block = this.workspace.newBlock('row_block'); + block.setEditable(false); + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'editable', false); + }); + + test('True', function() { + const block = this.workspace.newBlock('row_block'); + block.setEditable(true); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'editable'); + }); + }); + + suite('Inline', function() { + test('True', function() { + const block = this.workspace.newBlock('statement_block'); + block.setInputsInline(true); + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'inline', true); + }); + + test('False', function() { + const block = this.workspace.newBlock('statement_block'); + block.setInputsInline(false); + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'inline', false); + }); + + test('undefined', function() { + const block = this.workspace.newBlock('statement_block'); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'inline'); + }); + + test('True, matching default', function() { + const block = this.workspace.newBlock('statement_block'); + block.setInputsInline(true); + block.inputsInlineDefault = true; + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'inline'); + }); + + test('False, matching default', function() { + const block = this.workspace.newBlock('statement_block'); + block.setInputsInline(false); + block.inputsInlineDefault = false; + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'inline'); + }); + }); + + suite('Data', function() { + test('No data', function() { + const block = this.workspace.newBlock('row_block'); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'data'); + }); + + test('With data', function() { + const block = this.workspace.newBlock('row_block'); + block.data = 'some data'; + const jso = Blockly.serialization.blocks.save(block); + assertProperty(jso, 'data', 'some data'); + }); + }); + }); + + suite('Coords', function() { + test('No coordinates', function() { + const block = this.workspace.newBlock('row_block'); + const jso = Blockly.serialization.blocks.save(block); + assertNoProperty(jso, 'x'); + assertNoProperty(jso, 'y'); + }); + + test('Simple', function() { + const block = this.workspace.newBlock('row_block'); + block.moveBy(42, 42); + const jso = + Blockly.serialization.blocks.save(block, {addCoordinates: true}); + assertProperty(jso, 'x', 42); + assertProperty(jso, 'y', 42); + }); + + test('Negative', function() { + const block = this.workspace.newBlock('row_block'); + block.moveBy(-42, -42); + const jso = + Blockly.serialization.blocks.save(block, {addCoordinates: true}); + assertProperty(jso, 'x', -42); + assertProperty(jso, 'y', -42); + }); + + test('Zero', function() { + const block = this.workspace.newBlock('row_block'); + const jso = + Blockly.serialization.blocks.save(block, {addCoordinates: true}); + assertProperty(jso, 'x', 0); + assertProperty(jso, 'y', 0); + }); + }); + }); + }); +});