diff --git a/src/index.js b/src/index.js index e3ddc88a6b..839501e855 100644 --- a/src/index.js +++ b/src/index.js @@ -60,6 +60,7 @@ import { registerFieldTextInputRemovable } from "./fields/field_textinput_remova import { registerFieldVariableGetter } from "./fields/field_variable_getter.js"; import { registerFieldVariable } from "./fields/field_variable.js"; import { registerFieldVerticalSeparator } from "./fields/field_vertical_separator.js"; +import { registerRecyclableBlockFlyoutInflater } from "./recyclable_block_flyout_inflater.js"; export * from "blockly/core"; export * from "./block_reporting.js"; @@ -85,6 +86,7 @@ export function inject(container, options) { registerFieldVariableGetter(); registerFieldVariable(); registerFieldVerticalSeparator(); + registerRecyclableBlockFlyoutInflater(); Object.assign(options, { renderer: "scratch", diff --git a/src/recyclable_block_flyout_inflater.js b/src/recyclable_block_flyout_inflater.js new file mode 100644 index 0000000000..df96393eac --- /dev/null +++ b/src/recyclable_block_flyout_inflater.js @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; + +/** + * A block inflater that caches and reuses blocks to improve performance. + */ +export class RecyclableBlockFlyoutInflater extends Blockly.BlockFlyoutInflater { + recyclingEnabled = true; + recycledBlocks = new Map(); + + /** + * Toggles whether or not recycling is enabled. + * + * @param {boolean} enabled True if recycling should be enabled. + */ + setRecyclingEnabled(enabled) { + this.recyclingEnabled = enabled; + } + + /** + * Creates a new block from the given block definition. + * + * @param {Object} blockDefinition The definition to create a block from. + * @returns {!Blockly.BlockSvg} The newly created block. + */ + createBlock(blockDefinition) { + const blockType = this.getTypeFromDefinition(blockDefinition); + return ( + this.getRecycledBlock(blockType) ?? + super.createBlock(blockDefinition, this.flyoutWorkspace) + ); + } + + /** + * Returns the type of a block from an XML or JSON block definition. + * + * @param blockDefinition {Object} The block definition to parse. + * @returns {string} The block type. + */ + getTypeFromDefinition(blockDefinition) { + if (blockDefinition["blockxml"]) { + const xml = + typeof blockDefinition["blockxml"] === "string" + ? Blockly.utils.xml.textToDom(blockDefinition["blockxml"]) + : blockDefinition["blockxml"]; + return xml.getAttribute("type"); + } else { + return blockDefinition["type"]; + } + } + + /** + * Puts a previously created block into the recycle bin and moves it to the + * top of the workspace. Used during large workspace swaps to limit the number + * of new DOM elements we need to create. + * + * @param block The block to recycle. + */ + recycleBlock(block) { + const xy = block.getRelativeToSurfaceXY(); + block.moveBy(-xy.x, -xy.y); + this.recycledBlocks.set(block.type, block); + } + + /** + * Returns a block from the cache of recycled blocks with the given type, or + * undefined if one cannot be found. + * + * @param blockType The type of the block to try to recycle. + * @returns The recycled block, or undefined if + * one could not be recycled. + */ + getRecycledBlock(blockType) { + const block = this.recycledBlocks.get(blockType); + this.recycledBlocks.delete(blockType); + return block; + } + + /** + * Returns whether the given block can be recycled or not. + * + * @param block The block to check for recyclability. + * @returns True if the block can be recycled. False otherwise. + */ + blockIsRecyclable(block) { + if (!this.recyclingEnabled) { + return false; + } + + // If the block needs to parse mutations, never recycle. + if (block.mutationToDom && block.domToMutation) { + return false; + } + + if (!block.isEnabled()) { + return false; + } + + for (const input of block.inputList) { + for (const field of input.fieldRow) { + // No variables. + if (field.referencesVariables()) { + return false; + } + if (field instanceof Blockly.FieldDropdown) { + if (field.isOptionListDynamic()) { + return false; + } + } + } + // Check children. + if (input.connection) { + const targetBlock = + /** @type {Blockly.BlockSvg} */ + (input.connection.targetBlock()); + if (targetBlock && !this.blockIsRecyclable(targetBlock)) { + return false; + } + } + } + return true; + } + + /** + * Disposes of the provided block. + * + * @param {!Blockly.BlockSvg} element The block to dispose of. + */ + disposeElement(element) { + if (this.blockIsRecyclable(element)) { + this.removeListeners(element.id); + this.recycleBlock(element); + } else { + super.disposeElement(element); + } + } + + /** + * Clears the cache of recycled blocks. + */ + emptyRecycledBlocks() { + this.recycledBlocks + .values() + .forEach((block) => block.dispose(false, false)); + this.recycledBlocks.clear(); + } +} + +/** + * Registers the recyclable block flyout inflater. + */ +export function registerRecyclableBlockFlyoutInflater() { + Blockly.registry.unregister(Blockly.registry.Type.FLYOUT_INFLATER, "block"); + Blockly.registry.register( + Blockly.registry.Type.FLYOUT_INFLATER, + "block", + RecyclableBlockFlyoutInflater + ); +}