Files
blockly/core/xml.ts
Beka Westberg 42b8c6e3ab fix: return annotations (#7118)
* chore: remove suppress annotations

* chore: remove fileoverview annotation

* chore: remove other unused annotations

* chore: change return to returns

* chore: enable tag linting
2023-05-24 13:01:33 -07:00

1021 lines
32 KiB
TypeScript

/**
* @license
* Copyright 2012 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as goog from '../closure/goog/goog.js';
goog.declareModuleId('Blockly.Xml');
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 type {Field} from './field.js';
import {inputTypes} from './inputs/input_types.js';
import * as dom from './utils/dom.js';
import {Size} from './utils/size.js';
import * as utilsXml from './utils/xml.js';
import type {VariableModel} from './variable_model.js';
import * as Variables from './variables.js';
import type {Workspace} from './workspace.js';
import {WorkspaceComment} from './workspace_comment.js';
import {WorkspaceCommentSvg} from './workspace_comment_svg.js';
import type {WorkspaceSvg} from './workspace_svg.js';
/**
* Encode a block tree as XML.
*
* @param workspace The workspace containing blocks.
* @param opt_noId True if the encoder should skip the block IDs.
* @returns XML DOM element.
*/
export function workspaceToDom(
workspace: Workspace,
opt_noId?: boolean
): Element {
const treeXml = utilsXml.createElement('xml');
const variablesElement = variablesToDom(
Variables.allUsedVarModels(workspace)
);
if (variablesElement.hasChildNodes()) {
treeXml.appendChild(variablesElement);
}
const comments = workspace.getTopComments(true);
for (let i = 0; i < comments.length; i++) {
const comment = comments[i];
treeXml.appendChild(comment.toXmlWithXY(opt_noId));
}
const blocks = workspace.getTopBlocks(true);
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
treeXml.appendChild(blockToDomWithXY(block, opt_noId));
}
return treeXml;
}
/**
* Encode a list of variables as XML.
*
* @param variableList List of all variable models.
* @returns Tree of XML elements.
*/
export function variablesToDom(variableList: VariableModel[]): Element {
const variables = utilsXml.createElement('variables');
for (let i = 0; i < variableList.length; i++) {
const variable = variableList[i];
const element = utilsXml.createElement('variable');
element.appendChild(utilsXml.createTextNode(variable.name));
if (variable.type) {
element.setAttribute('type', variable.type);
}
element.id = variable.getId();
variables.appendChild(element);
}
return variables;
}
/**
* Encode a block subtree as XML with XY coordinates.
*
* @param block The root block to encode.
* @param opt_noId True if the encoder should skip the block ID.
* @returns Tree of XML elements or an empty document fragment if the block was
* an insertion marker.
*/
export function blockToDomWithXY(
block: Block,
opt_noId?: boolean
): Element | DocumentFragment {
if (block.isInsertionMarker()) {
// Skip over insertion markers.
block = block.getChildren(false)[0];
if (!block) {
// Disappears when appended.
return new DocumentFragment();
}
}
let width = 0; // Not used in LTR.
if (block.workspace.RTL) {
width = block.workspace.getWidth();
}
const element = blockToDom(block, opt_noId);
if (isElement(element)) {
const xy = block.getRelativeToSurfaceXY();
element.setAttribute(
'x',
String(Math.round(block.workspace.RTL ? width - xy.x : xy.x))
);
element.setAttribute('y', String(Math.round(xy.y)));
}
return element;
}
/**
* Encode a field as XML.
*
* @param field The field to encode.
* @returns XML element, or null if the field did not need to be serialized.
*/
function fieldToDom(field: Field): Element | null {
if (field.isSerializable()) {
const container = utilsXml.createElement('field');
container.setAttribute('name', field.name || '');
return field.toXml(container);
}
return null;
}
/**
* Encode all of a block's fields as XML and attach them to the given tree of
* XML elements.
*
* @param block A block with fields to be encoded.
* @param element The XML element to which the field DOM should be attached.
*/
function allFieldsToDom(block: Block, element: Element) {
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];
const fieldDom = fieldToDom(field);
if (fieldDom) {
element.appendChild(fieldDom);
}
}
}
}
/**
* Encode a block subtree as XML.
*
* @param block The root block to encode.
* @param opt_noId True if the encoder should skip the block ID.
* @returns Tree of XML elements or an empty document fragment if the block was
* an insertion marker.
*/
export function blockToDom(
block: Block,
opt_noId?: boolean
): Element | DocumentFragment {
// Skip over insertion markers.
if (block.isInsertionMarker()) {
const child = block.getChildren(false)[0];
if (child) {
return blockToDom(child);
} else {
// Disappears when appended.
return new DocumentFragment();
}
}
const element = utilsXml.createElement(block.isShadow() ? 'shadow' : 'block');
element.setAttribute('type', block.type);
if (!opt_noId) {
element.id = block.id;
}
if (block.mutationToDom) {
// Custom data for an advanced block.
const mutation = block.mutationToDom();
if (mutation && (mutation.hasChildNodes() || mutation.hasAttributes())) {
element.appendChild(mutation);
}
}
allFieldsToDom(block, element);
const commentText = block.getCommentText();
if (commentText) {
const size = block.commentModel.size;
const pinned = block.commentModel.pinned;
const commentElement = utilsXml.createElement('comment');
commentElement.appendChild(utilsXml.createTextNode(commentText));
commentElement.setAttribute('pinned', `${pinned}`);
commentElement.setAttribute('h', String(size.height));
commentElement.setAttribute('w', String(size.width));
element.appendChild(commentElement);
}
if (block.data) {
const dataElement = utilsXml.createElement('data');
dataElement.appendChild(utilsXml.createTextNode(block.data));
element.appendChild(dataElement);
}
for (let i = 0; i < block.inputList.length; i++) {
const input = block.inputList[i];
let container: Element;
let empty = true;
if (input.type === inputTypes.DUMMY) {
continue;
} else {
const childBlock = input.connection!.targetBlock();
if (input.type === inputTypes.VALUE) {
container = utilsXml.createElement('value');
} else if (input.type === inputTypes.STATEMENT) {
container = utilsXml.createElement('statement');
}
const childShadow = input.connection!.getShadowDom();
if (childShadow && (!childBlock || !childBlock.isShadow())) {
container!.appendChild(cloneShadow(childShadow, opt_noId));
}
if (childBlock) {
const childElem = blockToDom(childBlock, opt_noId);
if (childElem.nodeType === dom.NodeType.ELEMENT_NODE) {
container!.appendChild(childElem);
empty = false;
}
}
}
container!.setAttribute('name', input.name);
if (!empty) {
element.appendChild(container!);
}
}
if (
block.inputsInline !== undefined &&
block.inputsInline !== block.inputsInlineDefault
) {
element.setAttribute('inline', String(block.inputsInline));
}
if (block.isCollapsed()) {
element.setAttribute('collapsed', 'true');
}
if (!block.isEnabled()) {
element.setAttribute('disabled', 'true');
}
if (!block.isDeletable() && !block.isShadow()) {
element.setAttribute('deletable', 'false');
}
if (!block.isMovable() && !block.isShadow()) {
element.setAttribute('movable', 'false');
}
if (!block.isEditable()) {
element.setAttribute('editable', 'false');
}
const nextBlock = block.getNextBlock();
let container: Element;
if (nextBlock) {
const nextElem = blockToDom(nextBlock, opt_noId);
if (nextElem.nodeType === dom.NodeType.ELEMENT_NODE) {
container = utilsXml.createElement('next');
container.appendChild(nextElem);
element.appendChild(container);
}
}
const nextShadow =
block.nextConnection && block.nextConnection.getShadowDom();
if (nextShadow && (!nextBlock || !nextBlock.isShadow())) {
container!.appendChild(cloneShadow(nextShadow, opt_noId));
}
return element;
}
/**
* Deeply clone the shadow's DOM so that changes don't back-wash to the block.
*
* @param shadow A tree of XML elements.
* @param opt_noId True if the encoder should skip the block ID.
* @returns A tree of XML elements.
*/
function cloneShadow(shadow: Element, opt_noId?: boolean): Element {
shadow = shadow.cloneNode(true) as Element;
// Walk the tree looking for whitespace. Don't prune whitespace in a tag.
let node: Node | null = shadow;
let textNode;
while (node) {
if (opt_noId && node.nodeName === 'shadow') {
// Strip off IDs from shadow blocks. There should never be a 'block' as
// a child of a 'shadow', so no need to check that.
(node as Element).removeAttribute('id');
}
if (node.firstChild) {
node = node.firstChild;
} else {
while (node && !node.nextSibling) {
textNode = node;
node = node.parentNode;
if (
textNode.nodeType === dom.NodeType.TEXT_NODE &&
(textNode as Text).data.trim() === '' &&
node?.firstChild !== textNode
) {
// Prune whitespace after a tag.
dom.removeNode(textNode);
}
}
if (node) {
textNode = node;
node = node.nextSibling;
if (
textNode.nodeType === dom.NodeType.TEXT_NODE &&
(textNode as Text).data.trim() === ''
) {
// Prune whitespace before a tag.
dom.removeNode(textNode);
}
}
}
}
return shadow;
}
/**
* Converts a DOM structure into plain text.
* Currently the text format is fairly ugly: all one line with no whitespace,
* unless the DOM itself has whitespace built-in.
*
* @param dom A tree of XML nodes.
* @returns Text representation.
*/
export function domToText(dom: Node): string {
const text = utilsXml.domToText(dom);
// Unpack self-closing tags. These tags fail when embedded in HTML.
// <block name="foo"/> -> <block name="foo"></block>
return text.replace(/<(\w+)([^<]*)\/>/g, '<$1$2></$1>');
}
/**
* Converts a DOM structure into properly indented text.
*
* @param dom A tree of XML elements.
* @returns Text representation.
*/
export function domToPrettyText(dom: Node): string {
// This function is not guaranteed to be correct for all XML.
// But it handles the XML that Blockly generates.
const blob = domToText(dom);
// Place every open and close tag on its own line.
const lines = blob.split('<');
// Indent every line.
let indent = '';
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line[0] === '/') {
indent = indent.substring(2);
}
lines[i] = indent + '<' + line;
if (line[0] !== '/' && line.slice(-2) !== '/>') {
indent += ' ';
}
}
// Pull simple tags back together.
// E.g. <foo></foo>
let text = lines.join('\n');
text = text.replace(/(<(\w+)\b[^>]*>[^\n]*)\n *<\/\2>/g, '$1</$2>');
// Trim leading blank line.
return text.replace(/^\n/, '');
}
/**
* Clear the given workspace then decode an XML DOM and
* create blocks on the workspace.
*
* @param xml XML DOM.
* @param workspace The workspace.
* @returns An array containing new block IDs.
*/
export function clearWorkspaceAndLoadFromXml(
xml: Element,
workspace: WorkspaceSvg
): string[] {
workspace.setResizesEnabled(false);
workspace.clear();
const blockIds = domToWorkspace(xml, workspace);
workspace.setResizesEnabled(true);
return blockIds;
}
/**
* Decode an XML DOM and create blocks on the workspace.
*
* @param xml XML DOM.
* @param workspace The workspace.
* @returns An array containing new block IDs.
*/
export function domToWorkspace(xml: Element, workspace: Workspace): string[] {
let width = 0; // Not used in LTR.
if (workspace.RTL) {
width = workspace.getWidth();
}
const newBlockIds = []; // A list of block IDs added by this call.
dom.startTextWidthCache();
const existingGroup = eventUtils.getGroup();
if (!existingGroup) {
eventUtils.setGroup(true);
}
// Disable workspace resizes as an optimization.
// Assume it is rendered so we can check.
if ((workspace as WorkspaceSvg).setResizesEnabled) {
(workspace as WorkspaceSvg).setResizesEnabled(false);
}
let variablesFirst = true;
try {
for (let i = 0, xmlChild; (xmlChild = xml.childNodes[i]); i++) {
const name = xmlChild.nodeName.toLowerCase();
const xmlChildElement = xmlChild as Element;
if (
name === 'block' ||
(name === 'shadow' && !eventUtils.getRecordUndo())
) {
// Allow top-level shadow blocks if recordUndo is disabled since
// that means an undo is in progress. Such a block is expected
// to be moved to a nested destination in the next operation.
const block = domToBlock(xmlChildElement, workspace);
newBlockIds.push(block.id);
const blockX = parseInt(xmlChildElement.getAttribute('x') ?? '10', 10);
const blockY = parseInt(xmlChildElement.getAttribute('y') ?? '10', 10);
if (!isNaN(blockX) && !isNaN(blockY)) {
block.moveBy(workspace.RTL ? width - blockX : blockX, blockY, [
'create',
]);
}
variablesFirst = false;
} else if (name === 'shadow') {
throw TypeError('Shadow block cannot be a top-level block.');
} else if (name === 'comment') {
if (workspace.rendered) {
WorkspaceCommentSvg.fromXmlRendered(
xmlChildElement,
workspace as WorkspaceSvg,
width
);
} else {
WorkspaceComment.fromXml(xmlChildElement, workspace);
}
} else if (name === 'variables') {
if (variablesFirst) {
domToVariables(xmlChildElement, workspace);
} else {
throw Error(
"'variables' tag must exist once before block and " +
'shadow tag elements in the workspace XML, but it was found in ' +
'another location.'
);
}
variablesFirst = false;
}
}
} finally {
eventUtils.setGroup(existingGroup);
dom.stopTextWidthCache();
}
// Re-enable workspace resizing.
if ((workspace as WorkspaceSvg).setResizesEnabled) {
(workspace as WorkspaceSvg).setResizesEnabled(true);
}
eventUtils.fire(new (eventUtils.get(eventUtils.FINISHED_LOADING))(workspace));
return newBlockIds;
}
/**
* Decode an XML DOM and create blocks on the workspace. Position the new
* blocks immediately below prior blocks, aligned by their starting edge.
*
* @param xml The XML DOM.
* @param workspace The workspace to add to.
* @returns An array containing new block IDs.
*/
export function appendDomToWorkspace(
xml: Element,
workspace: WorkspaceSvg
): string[] {
// First check if we have a WorkspaceSvg, otherwise the blocks have no shape
// and the position does not matter.
// Assume it is rendered so we can check.
if (!(workspace as WorkspaceSvg).getBlocksBoundingBox) {
return domToWorkspace(xml, workspace);
}
const bbox = (workspace as WorkspaceSvg).getBlocksBoundingBox();
// Load the new blocks into the workspace and get the IDs of the new blocks.
const newBlockIds = domToWorkspace(xml, workspace);
if (bbox && bbox.top !== bbox.bottom) {
// Check if any previous block.
let offsetY = 0; // Offset to add to y of the new block.
let offsetX = 0;
const farY = bbox.bottom; // Bottom position.
const topX = workspace.RTL ? bbox.right : bbox.left; // X of bounding box.
// Check position of the new blocks.
let newLeftX = Infinity; // X of top left corner.
let newRightX = -Infinity; // X of top right corner.
let newY = Infinity; // Y of top corner.
const ySeparation = 10;
for (let i = 0; i < newBlockIds.length; i++) {
const blockXY = workspace
.getBlockById(newBlockIds[i])!
.getRelativeToSurfaceXY();
if (blockXY.y < newY) {
newY = blockXY.y;
}
if (blockXY.x < newLeftX) {
// if we left align also on x
newLeftX = blockXY.x;
}
if (blockXY.x > newRightX) {
// if we right align also on x
newRightX = blockXY.x;
}
}
offsetY = farY - newY + ySeparation;
offsetX = workspace.RTL ? topX - newRightX : topX - newLeftX;
for (let i = 0; i < newBlockIds.length; i++) {
const block = workspace.getBlockById(newBlockIds[i]);
block!.moveBy(offsetX, offsetY, ['create']);
}
}
return newBlockIds;
}
/**
* Decode an XML block tag and create a block (and possibly sub blocks) on the
* workspace.
*
* @param xmlBlock XML block element.
* @param workspace The workspace.
* @returns The root block created.
*/
export function domToBlock(xmlBlock: Element, workspace: Workspace): Block {
// Create top-level block.
eventUtils.disable();
const variablesBeforeCreation = workspace.getAllVariables();
let topBlock;
try {
topBlock = domToBlockHeadless(xmlBlock, workspace);
// Generate list of all blocks.
if (workspace.rendered) {
const topBlockSvg = topBlock as BlockSvg;
const blocks = topBlock.getDescendants(false);
topBlockSvg.setConnectionTracking(false);
// Render each block.
for (let i = blocks.length - 1; i >= 0; i--) {
(blocks[i] as BlockSvg).initSvg();
}
for (let i = blocks.length - 1; i >= 0; i--) {
(blocks[i] as BlockSvg).render(false);
}
// Populating the connection database may be deferred until after the
// blocks have rendered.
setTimeout(function () {
if (!topBlockSvg.disposed) {
topBlockSvg.setConnectionTracking(true);
}
}, 1);
topBlockSvg.updateDisabled();
// Allow the scrollbars to resize and move based on the new contents.
// TODO(@picklesrus): #387. Remove when domToBlock avoids resizing.
(workspace as WorkspaceSvg).resizeContents();
} else {
const blocks = topBlock.getDescendants(false);
for (let i = blocks.length - 1; i >= 0; i--) {
blocks[i].initModel();
}
}
} finally {
eventUtils.enable();
}
if (eventUtils.isEnabled()) {
const newVariables = Variables.getAddedVariables(
workspace,
variablesBeforeCreation
);
// Fire a VarCreate event for each (if any) new variable created.
for (let i = 0; i < newVariables.length; i++) {
const thisVariable = newVariables[i];
eventUtils.fire(
new (eventUtils.get(eventUtils.VAR_CREATE))(thisVariable)
);
}
// Block events come after var events, in case they refer to newly created
// variables.
eventUtils.fire(new (eventUtils.get(eventUtils.CREATE))(topBlock));
}
return topBlock;
}
/**
* Decode an XML list of variables and add the variables to the workspace.
*
* @param xmlVariables List of XML variable elements.
* @param workspace The workspace to which the variable should be added.
*/
export function domToVariables(xmlVariables: Element, workspace: Workspace) {
for (let i = 0; i < xmlVariables.children.length; i++) {
const xmlChild = xmlVariables.children[i];
const type = xmlChild.getAttribute('type');
const id = xmlChild.getAttribute('id');
const name = xmlChild.textContent;
if (!name) return;
workspace.createVariable(name, type, id);
}
}
/** A mapping of nodeName to node for child nodes of xmlBlock. */
interface childNodeTagMap {
mutation: Element[];
comment: Element[];
data: Element[];
field: Element[];
input: Element[];
next: Element[];
}
/**
* Creates a mapping of childNodes for each supported XML tag for the provided
* xmlBlock. Logs a warning for any encountered unsupported tags.
*
* @param xmlBlock XML block element.
* @returns The childNode map from nodeName to node.
*/
function mapSupportedXmlTags(xmlBlock: Element): childNodeTagMap {
const childNodeMap = {
mutation: new Array<Element>(),
comment: new Array<Element>(),
data: new Array<Element>(),
field: new Array<Element>(),
input: new Array<Element>(),
next: new Array<Element>(),
};
for (let i = 0; i < xmlBlock.children.length; i++) {
const xmlChild = xmlBlock.children[i];
if (xmlChild.nodeType === dom.NodeType.TEXT_NODE) {
// Ignore any text at the <block> level. It's all whitespace anyway.
continue;
}
switch (xmlChild.nodeName.toLowerCase()) {
case 'mutation':
childNodeMap.mutation.push(xmlChild);
break;
case 'comment':
childNodeMap.comment.push(xmlChild);
break;
case 'data':
childNodeMap.data.push(xmlChild);
break;
case 'title':
// Titles were renamed to field in December 2013.
// Fall through.
case 'field':
childNodeMap.field.push(xmlChild);
break;
case 'value':
case 'statement':
childNodeMap.input.push(xmlChild);
break;
case 'next':
childNodeMap.next.push(xmlChild);
break;
default:
// Unknown tag; ignore. Same principle as HTML parsers.
console.warn('Ignoring unknown tag: ' + xmlChild.nodeName);
}
}
return childNodeMap;
}
/**
* Applies mutation tag child nodes to the given block.
*
* @param xmlChildren Child nodes.
* @param block The block to apply the child nodes on.
* @returns True if mutation may have added some elements that need
* initialization (requiring initSvg call).
*/
function applyMutationTagNodes(xmlChildren: Element[], block: Block): boolean {
let shouldCallInitSvg = false;
for (let i = 0; i < xmlChildren.length; i++) {
const xmlChild = xmlChildren[i];
// Custom data for an advanced block.
if (block.domToMutation) {
block.domToMutation(xmlChild);
if ((block as BlockSvg).initSvg) {
// Mutation may have added some elements that need initializing.
shouldCallInitSvg = true;
}
}
}
return shouldCallInitSvg;
}
/**
* Applies comment tag child nodes to the given block.
*
* @param xmlChildren Child nodes.
* @param block The block to apply the child nodes on.
*/
function applyCommentTagNodes(xmlChildren: Element[], block: Block) {
for (let i = 0; i < xmlChildren.length; i++) {
const xmlChild = xmlChildren[i];
const text = xmlChild.textContent;
const pinned = xmlChild.getAttribute('pinned') === 'true';
const width = parseInt(xmlChild.getAttribute('w') ?? '50', 10);
const height = parseInt(xmlChild.getAttribute('h') ?? '50', 10);
block.setCommentText(text);
block.commentModel.pinned = pinned;
if (!isNaN(width) && !isNaN(height)) {
block.commentModel.size = new Size(width, height);
}
if (pinned && (block as BlockSvg).getCommentIcon && !block.isInFlyout) {
const blockSvg = block as BlockSvg;
setTimeout(function () {
blockSvg.getCommentIcon()!.setVisible(true);
}, 1);
}
}
}
/**
* Applies data tag child nodes to the given block.
*
* @param xmlChildren Child nodes.
* @param block The block to apply the child nodes on.
*/
function applyDataTagNodes(xmlChildren: Element[], block: Block) {
for (let i = 0; i < xmlChildren.length; i++) {
const xmlChild = xmlChildren[i];
block.data = xmlChild.textContent;
}
}
/**
* Applies field tag child nodes to the given block.
*
* @param xmlChildren Child nodes.
* @param block The block to apply the child nodes on.
*/
function applyFieldTagNodes(xmlChildren: Element[], block: Block) {
for (let i = 0; i < xmlChildren.length; i++) {
const xmlChild = xmlChildren[i];
const nodeName = xmlChild.getAttribute('name');
if (!nodeName) {
console.warn(`Ignoring unnamed field in block ${block.type}`);
continue;
}
domToField(block, nodeName, xmlChild);
}
}
/**
* Finds any enclosed blocks or shadows within this XML node.
*
* @param xmlNode The XML node to extract child block info from.
* @returns Any found child block.
*/
function findChildBlocks(xmlNode: Element): {
childBlockElement: Element | null;
childShadowElement: Element | null;
} {
let childBlockElement: Element | null = null;
let childShadowElement: Element | null = null;
for (let i = 0; i < xmlNode.childNodes.length; i++) {
const xmlChild = xmlNode.childNodes[i];
if (isElement(xmlChild)) {
if (xmlChild.nodeName.toLowerCase() === 'block') {
childBlockElement = xmlChild;
} else if (xmlChild.nodeName.toLowerCase() === 'shadow') {
childShadowElement = xmlChild;
}
}
}
return {childBlockElement, childShadowElement};
}
/**
* Applies input child nodes (value or statement) to the given block.
*
* @param xmlChildren Child nodes.
* @param workspace The workspace containing the given block.
* @param block The block to apply the child nodes on.
* @param prototypeName The prototype name of the block.
*/
function applyInputTagNodes(
xmlChildren: Element[],
workspace: Workspace,
block: Block,
prototypeName: string
) {
for (let i = 0; i < xmlChildren.length; i++) {
const xmlChild = xmlChildren[i];
const nodeName = xmlChild.getAttribute('name');
const input = nodeName ? block.getInput(nodeName) : null;
if (!input) {
console.warn(
'Ignoring non-existent input ' + nodeName + ' in block ' + prototypeName
);
break;
}
const childBlockInfo = findChildBlocks(xmlChild);
if (childBlockInfo.childBlockElement) {
if (!input.connection) {
throw TypeError('Input connection does not exist.');
}
domToBlockHeadless(
childBlockInfo.childBlockElement,
workspace,
input.connection,
false
);
}
// Set shadow after so we don't create a shadow we delete immediately.
if (childBlockInfo.childShadowElement) {
input.connection?.setShadowDom(childBlockInfo.childShadowElement);
}
}
}
/**
* Applies next child nodes to the given block.
*
* @param xmlChildren Child nodes.
* @param workspace The workspace containing the given block.
* @param block The block to apply the child nodes on.
*/
function applyNextTagNodes(
xmlChildren: Element[],
workspace: Workspace,
block: Block
) {
for (let i = 0; i < xmlChildren.length; i++) {
const xmlChild = xmlChildren[i];
const childBlockInfo = findChildBlocks(xmlChild);
if (childBlockInfo.childBlockElement) {
if (!block.nextConnection) {
throw TypeError('Next statement does not exist.');
}
// If there is more than one XML 'next' tag.
if (block.nextConnection.isConnected()) {
throw TypeError('Next statement is already connected.');
}
// Create child block.
domToBlockHeadless(
childBlockInfo.childBlockElement,
workspace,
block.nextConnection,
true
);
}
// Set shadow after so we don't create a shadow we delete immediately.
if (childBlockInfo.childShadowElement && block.nextConnection) {
block.nextConnection.setShadowDom(childBlockInfo.childShadowElement);
}
}
}
/**
* Decode an XML block tag and create a block (and possibly sub blocks) on the
* workspace.
*
* @param xmlBlock XML block element.
* @param workspace The workspace.
* @param parentConnection The parent connection to to connect this block to
* after instantiating.
* @param connectedToParentNext Whether the provided parent connection is a next
* connection, rather than output or statement.
* @returns The root block created.
*/
function domToBlockHeadless(
xmlBlock: Element,
workspace: Workspace,
parentConnection?: Connection,
connectedToParentNext?: boolean
): Block {
let block = null;
const prototypeName = xmlBlock.getAttribute('type');
if (!prototypeName) {
throw TypeError('Block type unspecified: ' + xmlBlock.outerHTML);
}
const id = xmlBlock.getAttribute('id') ?? undefined;
block = workspace.newBlock(prototypeName, id);
// Preprocess childNodes so tags can be processed in a consistent order.
const xmlChildNameMap = mapSupportedXmlTags(xmlBlock);
const shouldCallInitSvg = applyMutationTagNodes(
xmlChildNameMap.mutation,
block
);
applyCommentTagNodes(xmlChildNameMap.comment, block);
applyDataTagNodes(xmlChildNameMap.data, block);
// Connect parent after processing mutation and before setting fields.
if (parentConnection) {
if (connectedToParentNext) {
if (block.previousConnection) {
parentConnection.connect(block.previousConnection);
} else {
throw TypeError('Next block does not have previous statement.');
}
} else {
if (block.outputConnection) {
parentConnection.connect(block.outputConnection);
} else if (block.previousConnection) {
parentConnection.connect(block.previousConnection);
} else {
throw TypeError(
'Child block does not have output or previous statement.'
);
}
}
}
applyFieldTagNodes(xmlChildNameMap.field, block);
applyInputTagNodes(xmlChildNameMap.input, workspace, block, prototypeName);
applyNextTagNodes(xmlChildNameMap.next, workspace, block);
if (shouldCallInitSvg) {
// This shouldn't even be called here
// (ref: https://github.com/google/blockly/pull/4296#issuecomment-884226021
// But the XML serializer/deserializer is iceboxed so I'm not going to fix
// it.
(block as BlockSvg).initSvg();
}
const inline = xmlBlock.getAttribute('inline');
if (inline) {
block.setInputsInline(inline === 'true');
}
const disabled = xmlBlock.getAttribute('disabled');
if (disabled) {
block.setEnabled(disabled !== 'true' && disabled !== 'disabled');
}
const deletable = xmlBlock.getAttribute('deletable');
if (deletable) {
block.setDeletable(deletable === 'true');
}
const movable = xmlBlock.getAttribute('movable');
if (movable) {
block.setMovable(movable === 'true');
}
const editable = xmlBlock.getAttribute('editable');
if (editable) {
block.setEditable(editable === 'true');
}
const collapsed = xmlBlock.getAttribute('collapsed');
if (collapsed) {
block.setCollapsed(collapsed === 'true');
}
if (xmlBlock.nodeName.toLowerCase() === 'shadow') {
// Ensure all children are also shadows.
const children = block.getChildren(false);
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (!child.isShadow()) {
throw TypeError('Shadow block not allowed non-shadow child.');
}
}
// Ensure this block doesn't have any variable inputs.
if (block.getVarModels().length) {
throw TypeError('Shadow blocks cannot have variable references.');
}
block.setShadow(true);
}
return block;
}
/**
* Decode an XML field tag and set the value of that field on the given block.
*
* @param block The block that is currently being deserialized.
* @param fieldName The name of the field on the block.
* @param xml The field tag to decode.
*/
function domToField(block: Block, fieldName: string, xml: Element) {
const field = block.getField(fieldName);
if (!field) {
console.warn(
'Ignoring non-existent field ' + fieldName + ' in block ' + block.type
);
return;
}
field.fromXml(xml);
}
/**
* Remove any 'next' block (statements in a stack).
*
* @param xmlBlock XML block element or an empty DocumentFragment if the block
* was an insertion marker.
*/
export function deleteNext(xmlBlock: Element | DocumentFragment) {
for (let i = 0; i < xmlBlock.childNodes.length; i++) {
const child = xmlBlock.childNodes[i];
if (child.nodeName.toLowerCase() === 'next') {
xmlBlock.removeChild(child);
break;
}
}
}
function isElement(node: Node): node is Element {
return node.nodeType === dom.NodeType.ELEMENT_NODE;
}