/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Handles serializing blocks to plain JavaScript objects only * containing state. */ 'use strict'; /** * Handles serializing blocks to plain JavaScript objects only containing state. * @namespace Blockly.serialization.blocks */ goog.module('Blockly.serialization.blocks'); const Xml = goog.require('Blockly.Xml'); const eventUtils = goog.require('Blockly.Events.utils'); const priorities = goog.require('Blockly.serialization.priorities'); const serializationRegistry = goog.require('Blockly.serialization.registry'); const {BadConnectionCheck, MissingBlockType, MissingConnection, RealChildOfShadow} = goog.require('Blockly.serialization.exceptions'); /* eslint-disable-next-line no-unused-vars */ const {Block} = goog.requireType('Blockly.Block'); // eslint-disable-next-line no-unused-vars const {Connection} = goog.requireType('Blockly.Connection'); // eslint-disable-next-line no-unused-vars const {ISerializer} = goog.require('Blockly.serialization.ISerializer'); const {Size} = goog.require('Blockly.utils.Size'); // eslint-disable-next-line no-unused-vars const {Workspace} = goog.requireType('Blockly.Workspace'); const {inputTypes} = goog.require('Blockly.inputTypes'); // TODO(#5160): Remove this once lint is fixed. /* eslint-disable no-use-before-define */ /** * Represents the state of a connection. * @typedef {{ * shadow: (!State|undefined), * block: (!State|undefined) * }} * @alias Blockly.serialization.blocks.ConnectionState */ let ConnectionState; exports.ConnectionState = ConnectionState; /** * Represents the state of a given block. * @typedef {{ * type: string, * id: (string|undefined), * x: (number|undefined), * y: (number|undefined), * collapsed: (boolean|undefined), * enabled: (boolean|undefined), * inline: (boolean|undefined), * data: (string|undefined), * extra-state: (*|undefined), * icons: (!Object|undefined), * fields: (!Object|undefined), * inputs: (!Object|undefined), * next: (!ConnectionState|undefined) * }} * @alias Blockly.serialization.blocks.State */ let State; exports.State = State; /** * Returns the state of the given block as a plain JavaScript object. * @param {!Block} block The block to serialize. * @param {{addCoordinates: (boolean|undefined), addInputBlocks: * (boolean|undefined), addNextBlocks: (boolean|undefined), * doFullSerialization: (boolean|undefined)}=} param1 * addCoordinates: If true, the coordinates of the block are added to the * serialized state. False by default. * addinputBlocks: If true, children of the block which are connected to * inputs will be serialized. True by default. * addNextBlocks: If true, children of the block which are connected to the * block's next connection (if it exists) will be serialized. * True by default. * doFullSerialization: If true, fields that normally just save a reference * to some external state (eg variables) will instead serialize all of the * info about that state. This supports deserializing the block into a * workspace where that state doesn't yet exist. True 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). * @alias Blockly.serialization.blocks.save */ const save = function(block, { addCoordinates = false, addInputBlocks = true, addNextBlocks = true, doFullSerialization = true, } = {}) { if (block.isInsertionMarker()) { return null; } const state = { 'type': block.type, 'id': block.id, }; if (addCoordinates) { saveCoords(block, state); } saveAttributes(block, state); saveExtraState(block, state); saveIcons(block, state); saveFields(block, state, doFullSerialization); if (addInputBlocks) { saveInputBlocks(block, state, doFullSerialization); } if (addNextBlocks) { saveNextBlocks(block, state, doFullSerialization); } return state; }; exports.save = save; /** * Adds attributes to the given state object based on the state of the block. * Eg collapsed, disabled, inline, etc. * @param {!Block} block The block to base the attributes on. * @param {!State} state The state object to append to. */ const saveAttributes = function(block, state) { if (block.isCollapsed()) { state['collapsed'] = true; } if (!block.isEnabled()) { state['enabled'] = 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 {!Block} block The block to base the coordinates on. * @param {!State} state The state object to append to. */ const saveCoords = 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); }; /** * Adds any extra state the block may provide to the given state object. * @param {!Block} block The block to serialize the extra state of. * @param {!State} state The state object to append to. */ const saveExtraState = function(block, state) { if (block.saveExtraState) { const extraState = block.saveExtraState(); if (extraState !== null) { state['extraState'] = extraState; } } else if (block.mutationToDom) { const extraState = block.mutationToDom(); if (extraState !== null) { state['extraState'] = Xml.domToText(extraState) .replace( ' xmlns="https://developers.google.com/blockly/xml"', ''); } } }; /** * Adds the state of all of the icons on the block to the given state object. * @param {!Block} block The block to serialize the icon state of. * @param {!State} state The state object to append to. */ const saveIcons = function(block, state) { // TODO(#2105): Remove this logic and put it in the icon. if (block.getCommentText()) { state['icons'] = { 'comment': { 'text': block.getCommentText(), 'pinned': block.commentModel.pinned, 'height': Math.round(block.commentModel.size.height), 'width': Math.round(block.commentModel.size.width), }, }; } }; /** * Adds the state of all of the fields on the block to the given state object. * @param {!Block} block The block to serialize the field state of. * @param {!State} state The state object to append to. * @param {boolean} doFullSerialization Whether or not to serialize the full * state of the field (rather than possibly saving a reference to some * state). */ const saveFields = function(block, state, doFullSerialization) { const fields = Object.create(null); for (let i = 0; i < block.inputList.length; i++) { const input = block.inputList[i]; for (let j = 0; j < input.fieldRow.length; j++) { const field = input.fieldRow[j]; if (field.isSerializable()) { fields[field.name] = field.saveState(doFullSerialization); } } } if (Object.keys(fields).length) { state['fields'] = fields; } }; /** * Adds the state of all of the child blocks of the given block (which are * connected to inputs) to the given state object. * @param {!Block} block The block to serialize the input blocks of. * @param {!State} state The state object to append to. * @param {boolean} doFullSerialization Whether or not to do full serialization. */ const saveInputBlocks = function(block, state, doFullSerialization) { const inputs = Object.create(null); for (let i = 0; i < block.inputList.length; i++) { const input = block.inputList[i]; if (input.type === inputTypes.DUMMY) { continue; } const connectionState = saveConnection( /** @type {!Connection} */ (input.connection), doFullSerialization); if (connectionState) { inputs[input.name] = connectionState; } } if (Object.keys(inputs).length) { state['inputs'] = inputs; } }; /** * Adds the state of all of the next blocks of the given block to the given * state object. * @param {!Block} block The block to serialize the next blocks of. * @param {!State} state The state object to append to. * @param {boolean} doFullSerialization Whether or not to do full serialization. */ const saveNextBlocks = function(block, state, doFullSerialization) { if (!block.nextConnection) { return; } const connectionState = saveConnection(block.nextConnection, doFullSerialization); if (connectionState) { state['next'] = connectionState; } }; /** * Returns the state of the given connection (ie the state of any connected * shadow or real blocks). * @param {!Connection} connection The connection to serialize the connected * blocks of. * @return {?ConnectionState} An object containing the state of any connected * shadow block, or any connected real block. * @param {boolean} doFullSerialization Whether or not to do full serialization. */ const saveConnection = function(connection, doFullSerialization) { const shadow = connection.getShadowState(true); const child = connection.targetBlock(); if (!shadow && !child) { return null; } const state = Object.create(null); if (shadow) { state['shadow'] = shadow; } if (child && !child.isShadow()) { state['block'] = save(child, {doFullSerialization}); } return state; }; /** * Loads the block represented by the given state into the given workspace. * @param {!State} state The state of a block to deserialize into the workspace. * @param {!Workspace} workspace The workspace to add the block to. * @param {{recordUndo: (boolean|undefined)}=} param1 * recordUndo: If true, events triggered by this function will be undo-able * by the user. False by default. * @return {!Block} The block that was just loaded. * @alias Blockly.serialization.blocks.append */ const append = function(state, workspace, {recordUndo = false} = {}) { return appendInternal(state, workspace, {recordUndo}); }; exports.append = append; /** * Loads the block represented by the given state into the given workspace. * This is defined internally so that the extra parameters don't clutter our * external API. * But it is exported so that other places within Blockly can call it directly * with the extra parameters. * @param {!State} state The state of a block to deserialize into the workspace. * @param {!Workspace} workspace The workspace to add the block to. * @param {{parentConnection: (!Connection|undefined), isShadow: * (boolean|undefined), recordUndo: (boolean|undefined)}=} param1 * parentConnection: If provided, the system will attempt to connect the * block to this connection after it is created. Undefined by default. * isShadow: If true, the block will be set to a shadow block after it is * created. False by default. * recordUndo: If true, events triggered by this function will be undo-able * by the user. False by default. * @return {!Block} The block that was just appended. * @alias Blockly.serialization.blocks.appendInternal * @package */ const appendInternal = function(state, workspace, { parentConnection = undefined, isShadow = false, recordUndo = false, } = {}) { const prevRecordUndo = eventUtils.getRecordUndo(); eventUtils.setRecordUndo(recordUndo); const existingGroup = eventUtils.getGroup(); if (!existingGroup) { eventUtils.setGroup(true); } eventUtils.disable(); const block = appendPrivate(state, workspace, {parentConnection, isShadow}); eventUtils.enable(); eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(block)); eventUtils.setGroup(existingGroup); eventUtils.setRecordUndo(prevRecordUndo); // Adding connections to the connection db is expensive. This defers that // operation to decrease load time. if (workspace.rendered) { setTimeout(() => { if (!block.disposed) { block.setConnectionTracking(true); } }, 1); } return block; }; exports.appendInternal = appendInternal; /** * Loads the block represented by the given state into the given workspace. * This is defined privately so that it can be called recursively without firing * eroneous events. Events (and other things we only want to occur on the top * block) are handled by appendInternal. * @param {!State} state The state of a block to deserialize into the workspace. * @param {!Workspace} workspace The workspace to add the block to. * @param {{parentConnection: (!Connection|undefined), * isShadow: (boolean|undefined)}=} param1 * parentConnection: If provided, the system will attempt to connect the * block to this connection after it is created. Undefined by default. * isShadow: The block will be set to a shadow block after it is created. * False by default. * @return {!Block} The block that was just appended. */ const appendPrivate = function(state, workspace, { parentConnection = undefined, isShadow = false, } = {}) { if (!state['type']) { throw new MissingBlockType(state); } const block = workspace.newBlock(state['type'], state['id']); block.setShadow(isShadow); loadCoords(block, state); loadAttributes(block, state); loadExtraState(block, state); tryToConnectParent(parentConnection, block, state); loadIcons(block, state); loadFields(block, state); loadInputBlocks(block, state); loadNextBlocks(block, state); initBlock(block, workspace.rendered); return block; }; /** * Applies any coordinate information available on the state object to the * block. * @param {!Block} block The block to set the position of. * @param {!State} state The state object to reference. */ const loadCoords = function(block, state) { let x = state['x'] === undefined ? 0 : state['x']; const y = state['y'] === undefined ? 0 : state['y']; const workspace = block.workspace; x = workspace.RTL ? workspace.getWidth() - x : x; block.moveBy(x, y); }; /** * Applies any attribute information available on the state object to the block. * @param {!Block} block The block to set the attributes of. * @param {!State} state The state object to reference. */ const loadAttributes = function(block, state) { if (state['collapsed']) { block.setCollapsed(true); } if (state['enabled'] === false) { block.setEnabled(false); } if (state['inline'] !== undefined) { block.setInputsInline(state['inline']); } if (state['data'] !== undefined) { block.data = state['data']; } }; /** * Applies any extra state information available on the state object to the * block. * @param {!Block} block The block to set the extra state of. * @param {!State} state The state object to reference. */ const loadExtraState = function(block, state) { if (!state['extraState']) { return; } if (block.loadExtraState) { block.loadExtraState(state['extraState']); } else { block.domToMutation(Xml.textToDom(state['extraState'])); } }; /** * Attempts to connect the block to the parent connection, if it exists. * @param {(!Connection|undefined)} parentConnection The parent connection to * try to connect the block to. * @param {!Block} child The block to try to connect to the parent. * @param {!State} state The state which defines the given block */ const tryToConnectParent = function(parentConnection, child, state) { if (!parentConnection) { return; } if (parentConnection.getSourceBlock().isShadow() && !child.isShadow()) { throw new RealChildOfShadow(state); } let connected = false; let childConnection; if (parentConnection.type === inputTypes.VALUE) { childConnection = child.outputConnection; if (!childConnection) { throw new MissingConnection('output', child, state); } connected = parentConnection.connect(childConnection); } else { // Statement type. childConnection = child.previousConnection; if (!childConnection) { throw new MissingConnection('previous', child, state); } connected = parentConnection.connect(childConnection); } if (!connected) { const checker = child.workspace.connectionChecker; throw new BadConnectionCheck( checker.getErrorMessage( checker.canConnectWithReason( childConnection, parentConnection, false), childConnection, parentConnection), parentConnection.type === inputTypes.VALUE ? 'output connection' : 'previous connection', child, state); } }; /** * Applies icon state to the icons on the block, based on the given state * object. * @param {!Block} block The block to set the icon state of. * @param {!State} state The state object to reference. */ const loadIcons = function(block, state) { if (!state['icons']) { return; } // TODO(#2105): Remove this logic and put it in the icon. const comment = state['icons']['comment']; if (comment) { block.setCommentText(comment['text']); block.commentModel.pinned = comment['pinned']; block.commentModel.size = new Size(comment['width'], comment['height']); if (comment['pinned'] && block.getCommentIcon && !block.isInFlyout) { // Give the block a chance to be positioned and rendered before showing. setTimeout(() => block.getCommentIcon().setVisible(true), 1); } } }; /** * Applies any field information available on the state object to the block. * @param {!Block} block The block to set the field state of. * @param {!State} state The state object to reference. */ const loadFields = function(block, state) { if (!state['fields']) { return; } const keys = Object.keys(state['fields']); for (let i = 0; i < keys.length; i++) { const fieldName = keys[i]; const fieldState = state['fields'][fieldName]; const field = block.getField(fieldName); if (!field) { console.warn( `Ignoring non-existant field ${fieldName} in block ${block.type}`); continue; } field.loadState(fieldState); } }; /** * Creates any child blocks (attached to inputs) defined by the given state * and attaches them to the given block. * @param {!Block} block The block to attach input blocks to. * @param {!State} state The state object to reference. */ const loadInputBlocks = function(block, state) { if (!state['inputs']) { return; } const keys = Object.keys(state['inputs']); for (let i = 0; i < keys.length; i++) { const inputName = keys[i]; const input = block.getInput(inputName); if (!input || !input.connection) { throw new MissingConnection(inputName, block, state); } loadConnection(input.connection, state['inputs'][inputName]); } }; /** * Creates any next blocks defined by the given state and attaches them to the * given block. * @param {!Block} block The block to attach next blocks to. * @param {!State} state The state object to reference. */ const loadNextBlocks = function(block, state) { if (!state['next']) { return; } if (!block.nextConnection) { throw new MissingConnection('next', block, state); } loadConnection(block.nextConnection, state['next']); }; /** * Applies the state defined by connectionState to the given connection, ie * assigns shadows and attaches child blocks. * @param {!Connection} connection The connection to deserialize the * connected blocks of. * @param {!ConnectionState} connectionState The object containing the state of * any connected shadow block, or any connected real block. */ const loadConnection = function(connection, connectionState) { if (connectionState['shadow']) { connection.setShadowState(connectionState['shadow']); } if (connectionState['block']) { appendPrivate( connectionState['block'], connection.getSourceBlock().workspace, {parentConnection: connection}); } }; // TODO(#5146): Remove this from the serialization system. /** * Initializes the give block, eg init the model, inits the svg, renders, etc. * @param {!Block} block The block to initialize. * @param {boolean} rendered Whether the block is a rendered or headless block. */ const initBlock = function(block, rendered) { if (rendered) { // Adding connections to the connection db is expensive. This defers that // operation to decrease load time. block.setConnectionTracking(false); block.initSvg(); block.render(false); } else { block.initModel(); } }; // Alias to disambiguate saving within the serializer. const saveBlock = save; /** * Serializer for saving and loading block state. * @implements {ISerializer} * @alias Blockly.serialization.blocks.BlockSerializer */ class BlockSerializer { /* eslint-disable-next-line require-jsdoc */ constructor() { /** * The priority for deserializing blocks. * @type {number} */ this.priority = priorities.BLOCKS; } /** * Serializes the blocks of the given workspace. * @param {!Workspace} workspace The workspace to save the blocks of. * @return {?{languageVersion: number, blocks:!Array}} The state of * the workspace's blocks, or null if there are no blocks. */ save(workspace) { const blockStates = []; for (const block of workspace.getTopBlocks(false)) { const state = saveBlock(block, {addCoordinates: true, doFullSerialization: false}); if (state) { blockStates.push(state); } } if (blockStates.length) { return { 'languageVersion': 0, // Currently unused. 'blocks': blockStates, }; } return null; } /** * Deserializes the blocks defined by the given state into the given * workspace. * @param {{languageVersion: number, blocks:!Array}} state The state * of the blocks to deserialize. * @param {!Workspace} workspace The workspace to deserialize into. */ load(state, workspace) { const blockStates = state['blocks']; for (const state of blockStates) { append(state, workspace, {recordUndo: eventUtils.getRecordUndo()}); } } /** * Disposes of any blocks that exist on the workspace. * @param {!Workspace} workspace The workspace to clear the blocks of. */ clear(workspace) { // Cannot use workspace.clear() because that also removes variables. for (const block of workspace.getTopBlocks(false)) { block.dispose(false); } } } serializationRegistry.register('blocks', new BlockSerializer());