/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as goog from '../../closure/goog/goog.js'; goog.declareModuleId('Blockly.serialization.blocks'); import type {Block} from '../block.js'; import type {BlockSvg} from '../block_svg.js'; import type {Connection} from '../connection.js'; import * as eventUtils from '../events/utils.js'; import {inputTypes} from '../inputs/input_types.js'; import {isSerializable} from '../interfaces/i_serializable.js'; import type {ISerializer} from '../interfaces/i_serializer.js'; import * as registry from '../registry.js'; import * as utilsXml from '../utils/xml.js'; import type {Workspace} from '../workspace.js'; import * as Xml from '../xml.js'; import * as renderManagement from '../render_management.js'; import { BadConnectionCheck, MissingBlockType, MissingConnection, RealChildOfShadow, UnregisteredIcon, } from './exceptions.js'; import * as priorities from './priorities.js'; import * as serializationRegistry from './registry.js'; // TODO(#5160): Remove this once lint is fixed. /* eslint-disable no-use-before-define */ /** * Represents the state of a connection. */ export interface ConnectionState { shadow?: State; block?: State; } /** * Represents the state of a given block. */ export interface State { type: string; id?: string; x?: number; y?: number; collapsed?: boolean; deletable?: boolean; movable?: boolean; editable?: boolean; enabled?: boolean; inline?: boolean; data?: string; extraState?: AnyDuringMigration; icons?: {[key: string]: AnyDuringMigration}; fields?: {[key: string]: AnyDuringMigration}; inputs?: {[key: string]: ConnectionState}; next?: ConnectionState; } /** * Returns the state of the given block as a plain JavaScript object. * * @param block The block to serialize. * @param 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. * @returns The serialized state of the block, or null if the block could not be * serialied (eg it was an insertion marker). */ export function save( block: Block, { addCoordinates = false, addInputBlocks = true, addNextBlocks = true, doFullSerialization = true, }: { addCoordinates?: boolean; addInputBlocks?: boolean; addNextBlocks?: boolean; doFullSerialization?: boolean; } = {}, ): State | null { if (block.isInsertionMarker()) { return null; } const state = { 'type': block.type, 'id': block.id, }; if (addCoordinates) { // AnyDuringMigration because: Argument of type '{ type: string; id: // string; }' is not assignable to parameter of type 'State'. saveCoords(block, state as AnyDuringMigration); } // AnyDuringMigration because: Argument of type '{ type: string; id: string; // }' is not assignable to parameter of type 'State'. saveAttributes(block, state as AnyDuringMigration); // AnyDuringMigration because: Argument of type '{ type: string; id: string; // }' is not assignable to parameter of type 'State'. saveExtraState(block, state as AnyDuringMigration, doFullSerialization); // AnyDuringMigration because: Argument of type '{ type: string; id: string; // }' is not assignable to parameter of type 'State'. saveIcons(block, state as AnyDuringMigration, doFullSerialization); // AnyDuringMigration because: Argument of type '{ type: string; id: string; // }' is not assignable to parameter of type 'State'. saveFields(block, state as AnyDuringMigration, doFullSerialization); if (addInputBlocks) { // AnyDuringMigration because: Argument of type '{ type: string; id: // string; }' is not assignable to parameter of type 'State'. saveInputBlocks(block, state as AnyDuringMigration, doFullSerialization); } if (addNextBlocks) { // AnyDuringMigration because: Argument of type '{ type: string; id: // string; }' is not assignable to parameter of type 'State'. saveNextBlocks(block, state as AnyDuringMigration, doFullSerialization); } // AnyDuringMigration because: Type '{ type: string; id: string; }' is not // assignable to type 'State'. return state as AnyDuringMigration; } /** * Adds attributes to the given state object based on the state of the block. * Eg collapsed, disabled, inline, etc. * * @param block The block to base the attributes on. * @param state The state object to append to. */ function saveAttributes(block: Block, state: State) { if (block.isCollapsed()) { state['collapsed'] = true; } if (!block.isEnabled()) { state['enabled'] = false; } if (!block.isOwnDeletable()) { state['deletable'] = false; } if (!block.isOwnMovable()) { state['movable'] = false; } if (!block.isOwnEditable()) { state['editable'] = 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 The block to base the coordinates on. * @param state The state object to append to. */ function saveCoords(block: Block, state: 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 The block to serialize the extra state of. * @param state The state object to append to. * @param doFullSerialization Whether or not to serialize the full state of the * extra state (rather than possibly saving a reference to some state). */ function saveExtraState( block: Block, state: State, doFullSerialization: boolean, ) { if (block.saveExtraState) { const extraState = block.saveExtraState(doFullSerialization); 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 The block to serialize the icon state of. * @param state The state object to append to. * @param doFullSerialization Whether or not to serialize the full state of the * icon (rather than possibly saving a reference to some state). */ function saveIcons(block: Block, state: State, doFullSerialization: boolean) { const icons = Object.create(null); for (const icon of block.getIcons()) { if (isSerializable(icon)) { const state = icon.saveState(doFullSerialization); if (state) icons[icon.getType().toString()] = state; } } if (Object.keys(icons).length) { state['icons'] = icons; } } /** * Adds the state of all of the fields on the block to the given state object. * * @param block The block to serialize the field state of. * @param state The state object to append to. * @param doFullSerialization Whether or not to serialize the full state of the * field (rather than possibly saving a reference to some state). */ function saveFields(block: Block, state: State, doFullSerialization: boolean) { 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 The block to serialize the input blocks of. * @param state The state object to append to. * @param doFullSerialization Whether or not to do full serialization. */ function saveInputBlocks( block: Block, state: State, doFullSerialization: boolean, ) { const inputs = Object.create(null); for (let i = 0; i < block.inputList.length; i++) { const input = block.inputList[i]; if (!input.connection) continue; const connectionState = saveConnection( input.connection as 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 The block to serialize the next blocks of. * @param state The state object to append to. * @param doFullSerialization Whether or not to do full serialization. */ function saveNextBlocks( block: Block, state: State, doFullSerialization: boolean, ) { 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 The connection to serialize the connected blocks of. * @returns An object containing the state of any connected shadow block, or any * connected real block. * @param doFullSerialization Whether or not to do full serialization. */ function saveConnection( connection: Connection, doFullSerialization: boolean, ): ConnectionState | null { 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 The state of a block to deserialize into the workspace. * @param workspace The workspace to add the block to. * @param param1 recordUndo: If true, events triggered by this function will be * undo-able by the user. False by default. * @returns The block that was just loaded. */ export function append( state: State, workspace: Workspace, {recordUndo = false}: {recordUndo?: boolean} = {}, ): Block { const block = appendInternal(state, workspace, {recordUndo}); if (workspace.rendered) renderManagement.triggerQueuedRenders(); return block; } /** * 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 The state of a block to deserialize into the workspace. * @param workspace The workspace to add the block to. * @param 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. * @returns The block that was just appended. * @internal */ export function appendInternal( state: State, workspace: Workspace, { parentConnection = undefined, isShadow = false, recordUndo = false, }: { parentConnection?: Connection; isShadow?: boolean; recordUndo?: boolean; } = {}, ): Block { 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(); if (eventUtils.isEnabled()) { 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) { const blockSvg = block as BlockSvg; setTimeout(() => { if (!blockSvg.disposed) { blockSvg.setConnectionTracking(true); } }, 1); } return block; } /** * 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 The state of a block to deserialize into the workspace. * @param workspace The workspace to add the block to. * @param 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. * @returns The block that was just appended. */ function appendPrivate( state: State, workspace: Workspace, { parentConnection = undefined, isShadow = false, }: {parentConnection?: Connection; isShadow?: boolean} = {}, ): Block { 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 The block to set the position of. * @param state The state object to reference. */ function loadCoords(block: Block, state: 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 The block to set the attributes of. * @param state The state object to reference. */ function loadAttributes(block: Block, state: State) { if (state['collapsed']) { block.setCollapsed(true); } if (state['deletable'] === false) { block.setDeletable(false); } if (state['movable'] === false) { block.setMovable(false); } if (state['editable'] === false) { block.setEditable(false); } 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 The block to set the extra state of. * @param state The state object to reference. */ function loadExtraState(block: Block, state: State) { if (!state['extraState']) { return; } if (block.loadExtraState) { block.loadExtraState(state['extraState']); } else if (block.domToMutation) { block.domToMutation(utilsXml.textToDom(state['extraState'])); } } /** * Attempts to connect the block to the parent connection, if it exists. * * @param parentConnection The parent connection to try to connect the block to. * @param child The block to try to connect to the parent. * @param state The state which defines the given block */ function tryToConnectParent( parentConnection: Connection | undefined, child: Block, state: 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 The block to set the icon state of. * @param state The state object to reference. */ function loadIcons(block: Block, state: State) { if (!state['icons']) return; const iconTypes = Object.keys(state['icons']); for (const iconType of iconTypes) { const iconState = state['icons'][iconType]; let icon = block.getIcon(iconType); if (!icon) { const constructor = registry.getClass( registry.Type.ICON, iconType, false, ); if (!constructor) throw new UnregisteredIcon(iconType, block, state); icon = new constructor(block); block.addIcon(icon); } if (isSerializable(icon)) icon.loadState(iconState); } } /** * Applies any field information available on the state object to the block. * * @param block The block to set the field state of. * @param state The state object to reference. */ function loadFields(block: Block, state: 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 The block to attach input blocks to. * @param state The state object to reference. */ function loadInputBlocks(block: Block, state: 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 The block to attach next blocks to. * @param state The state object to reference. */ function loadNextBlocks(block: Block, state: 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 The connection to deserialize the connected blocks of. * @param connectionState The object containing the state of any connected * shadow block, or any connected real block. */ function loadConnection( connection: Connection, connectionState: 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 The block to initialize. * @param rendered Whether the block is a rendered or headless block. */ function initBlock(block: Block, rendered: boolean) { if (rendered) { const blockSvg = block as BlockSvg; // Adding connections to the connection db is expensive. This defers that // operation to decrease load time. blockSvg.setConnectionTracking(false); blockSvg.initSvg(); blockSvg.queueRender(); blockSvg.updateDisabled(); // fixes #6076 JSO deserialization doesn't // set .iconXY_ property so here it will be set for (const icon of blockSvg.getIcons()) { icon.onLocationChange(blockSvg.getRelativeToSurfaceXY()); } } else { block.initModel(); } } // Alias to disambiguate saving within the serializer. const saveBlock = save; /** * Serializer for saving and loading block state. */ export class BlockSerializer implements ISerializer { priority: number; /* eslint-disable-next-line require-jsdoc */ constructor() { /** The priority for deserializing blocks. */ this.priority = priorities.BLOCKS; } /** * Serializes the blocks of the given workspace. * * @param workspace The workspace to save the blocks of. * @returns The state of the workspace's blocks, or null if there are no * blocks. */ save( workspace: Workspace, ): {languageVersion: number; blocks: State[]} | null { 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 state The state of the blocks to deserialize. * @param workspace The workspace to deserialize into. */ load( state: {languageVersion: number; blocks: State[]}, workspace: 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 The workspace to clear the blocks of. */ clear(workspace: 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());