Files
blockly/core/serialization/blocks.ts
Christopher Allen b0a7c004a9 refactor(build): Delete Closure Library (#7415)
* fix(build): Restore erroneously-deleted filter function

  This was deleted in PR #7406 as it was mainly being used to
  filter core/ vs. test/mocha/ deps into separate deps files -
  but it turns out also to be used for filtering error
  messages too.  Oops.

* refactor(tests): Migrate advanced compilation test to ES Modules

* refactor(build): Migrate main.js to TypeScript

  This turns out to be pretty straight forward, even if it would
  cause crashing if one actually tried to import this module
  instead of just feeding it to Closure Compiler.

* chore(build): Remove goog.declareModuleId calls

  Replace goog.declareModuleId calls with a comment recording the
  former module ID for posterity (or at least until we decide
  how to reformat the renamings file.

* chore(tests): Delete closure/goog/*

  For the moment we still need something to serve as base.js for
  the benefit of closure-make-deps, so we keep a vestigial
  base.js around, containing only the @provideGoog declaration.

* refactor(build): Remove vestigial base.js

  By changing slightly the command line arguments to
  closure-make-deps and closure-calculate-chunks the need to have
  any base.js is eliminated.

* chore: Typo fix for PR #7415
2023-08-31 00:24:47 +01:00

803 lines
24 KiB
TypeScript

/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: 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());