UNPKG

@blockly/workspace-backpack

Version:
1,040 lines (948 loc) 31.5 kB
/** * @license * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview A backpack that lives on top of the workspace. * @author kozbial@google.com (Monica Kozbial) */ /* eslint-disable @typescript-eslint/naming-convention */ import * as Blockly from 'blockly/core'; import {registerContextMenus} from './backpack_helpers'; import {BackpackOptions, parseOptions} from './options'; import {BackpackChange, BackpackOpen} from './ui_events'; import {Backpackable, isBackpackable} from './backpackable'; /** * Class for backpack that can be used save blocks from the workspace for * future use. */ export class Backpack extends Blockly.DragTarget implements Blockly.IAutoHideable, Blockly.IPositionable { /** The unique id for this component. */ id = 'backpack'; /** * The backpack options, such as which context menus to show, whether to * allow opening the backpack when empty and whether to use a different * image when the backpack contains blocks. */ private options: BackpackOptions; /** The backpack flyout. Initialized during init. */ protected flyout_: Blockly.IFlyout | null = null; /** A list of JSON (stored as strings) representing blocks in the backpack. */ protected contents_: string[] = []; /** Array holding info needed to unbind events. Used for disposing. */ private boundEvents: Blockly.browserEvents.Data[] = []; /** Left coordinate of the backpack in pixels. */ protected left_ = 0; /** Top coordinate of the backpack in pixels. */ protected top_ = 0; /** Width of the backpack in pixels. Used for clip path. */ protected readonly WIDTH_ = 40; /** Height of the backpack in pixels. Used for clip path. */ protected readonly HEIGHT_ = 60; /** Distance between backpack and bottom/top edge of workspace in pixels. */ protected readonly MARGIN_VERTICAL_ = 20; /** Distance between backpack and right/left edge of workspace in pixels. */ protected readonly MARGIN_HORIZONTAL_ = 20; /** Extent of hotspot on all sides beyond the size of the image in pixels. */ protected readonly HOTSPOT_MARGIN_ = 10; /** The SVG group containing the backpack. */ protected svgGroup_: SVGElement | null = null; /** The SVG image of the backpack. */ protected svgImg_: SVGImageElement | null = null; /** Top offset for backpack svg in pixels. */ private spriteTop = 10; /** Left offset for backpack svg in pixels. */ private spriteLeft = 20; /** Width/Height of svg in pixels. */ private readonly spriteSize = 80; /** Whether or not the plugin has been initialized. */ protected initialized_ = false; /** * Constructor for a backpack. * * @param workspace_ The target workspace that * the backpack will be added to. * @param backpackOptions The backpack options to use. */ constructor( protected workspace_: Blockly.WorkspaceSvg, backpackOptions?: BackpackOptions, ) { super(); this.options = parseOptions(backpackOptions); this.registerSerializer(); } /** * Registers serializer if options.skipSerializerRegistration is false * and it hasn't been registered already. */ private registerSerializer() { if (this.options.skipSerializerRegistration) { return; } if ( Blockly.registry.hasItem(Blockly.registry.Type.SERIALIZER, 'backpack') ) { return; } Blockly.serialization.registry.register( 'backpack', new BackpackSerializer(), ); } /** * Initializes the backpack. */ init() { this.workspace_.getComponentManager().addComponent({ component: this, weight: 2, capabilities: [ Blockly.ComponentManager.Capability.AUTOHIDEABLE, Blockly.ComponentManager.Capability.DRAG_TARGET, Blockly.ComponentManager.Capability.POSITIONABLE, ], }); this.initFlyout(); this.createDom(); this.attachListeners(); if (this.options.contextMenu) { registerContextMenus(this.options.contextMenu, this.workspace_); } this.initialized_ = true; this.workspace_.resize(); } /** * Disposes of workspace search. * Unlink from all DOM elements and remove all event listeners * to prevent memory leaks. */ dispose() { if (this.svgGroup_) { Blockly.utils.dom.removeNode(this.svgGroup_); } for (const event of this.boundEvents) { Blockly.browserEvents.unbind(event); } this.boundEvents.length = 0; } /** * Creates and initializes the flyout and inserts it into the dom. */ protected initFlyout() { // Create flyout options. const flyoutWorkspaceOptions = new Blockly.Options({ scrollbars: true, parentWorkspace: this.workspace_, rtl: this.workspace_.RTL, oneBasedIndex: this.workspace_.options.oneBasedIndex, renderer: this.workspace_.options.renderer, rendererOverrides: this.workspace_.options.rendererOverrides || undefined, move: { scrollbars: true, }, }); // Create vertical or horizontal flyout. if (this.workspace_.horizontalLayout) { flyoutWorkspaceOptions.toolboxPosition = this.workspace_.toolboxPosition === Blockly.utils.toolbox.Position.TOP ? Blockly.utils.toolbox.Position.BOTTOM : Blockly.utils.toolbox.Position.TOP; const HorizontalFlyout = Blockly.registry.getClassFromOptions( Blockly.registry.Type.FLYOUTS_HORIZONTAL_TOOLBOX, this.workspace_.options, true, ); if (HorizontalFlyout) { this.flyout_ = new HorizontalFlyout(flyoutWorkspaceOptions); } else { throw new Error('HorizontalFlyout does not exist'); } } else { flyoutWorkspaceOptions.toolboxPosition = this.workspace_.toolboxPosition === Blockly.utils.toolbox.Position.RIGHT ? Blockly.utils.toolbox.Position.LEFT : Blockly.utils.toolbox.Position.RIGHT; const VerticalFlyout = Blockly.registry.getClassFromOptions( Blockly.registry.Type.FLYOUTS_VERTICAL_TOOLBOX, this.workspace_.options, true, ); if (VerticalFlyout) { this.flyout_ = new VerticalFlyout(flyoutWorkspaceOptions); } else { throw new Error('VerticalFlyout does not exist'); } } // Add flyout to DOM. const parentNode = this.workspace_.getParentSvg().parentNode; parentNode?.appendChild(this.flyout_?.createDom(Blockly.utils.Svg.SVG)); this.flyout_.init(this.workspace_); } /** * Creates DOM for UI element. */ protected createDom() { this.svgGroup_ = Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.G, {}, null, ); const rnd = Blockly.utils.idGenerator.genUid(); const clip = Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.CLIPPATH, {id: 'blocklyBackpackClipPath' + rnd}, this.svgGroup_, ); Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.RECT, { width: this.WIDTH_, height: this.HEIGHT_, }, clip, ); this.svgImg_ = Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.IMAGE, { 'class': 'blocklyBackpack', 'clip-path': 'url(#blocklyBackpackClipPath' + rnd + ')', 'width': this.spriteSize + 'px', 'x': -this.spriteLeft, 'height': this.spriteSize + 'px', 'y': -this.spriteTop, }, this.svgGroup_, ); this.svgImg_.setAttributeNS( Blockly.utils.dom.XLINK_NS, 'xlink:href', backpackSvgDataUri, ); Blockly.utils.dom.insertAfter( this.svgGroup_, this.workspace_.getBubbleCanvas(), ); } /** * Attaches event listeners. */ protected attachListeners() { if (!this.svgGroup_) return; this.addEvent( this.svgGroup_, 'mousedown', this, this.blockMouseDownWhenOpenable, ); this.addEvent(this.svgGroup_, 'mouseup', this, this.onClick); this.addEvent(this.svgGroup_, 'mouseover', this, this.onMouseOver); this.addEvent(this.svgGroup_, 'mouseout', this, this.onMouseOut); } /** * Helper method for adding an event. * * @param node Node upon which to listen. * @param name Event name to listen to (e.g. 'mousedown'). * @param thisObject The value of 'this' in the function. * @param func Function to call when event is triggered. */ private addEvent( node: Element, name: string, thisObject: object, func: (event: Event) => void, ) { const event = Blockly.browserEvents.bind(node, name, thisObject, func); this.boundEvents.push(event); } /** * Returns the backpack flyout. * * @returns The backpack flyout. */ getFlyout(): Blockly.IFlyout | null { return this.flyout_; } /** * Returns the bounding rectangle of the drag target area in pixel units * relative to viewport. * * @returns The component's bounding box. Null if drag * target area should be ignored. */ getClientRect(): Blockly.utils.Rect | null { if (!this.svgGroup_) { return null; } const clientRect = this.svgGroup_.getBoundingClientRect(); const top = clientRect.top + this.spriteTop - this.HOTSPOT_MARGIN_; const bottom = top + this.HEIGHT_ + 2 * this.HOTSPOT_MARGIN_; const left = clientRect.left + this.spriteLeft - this.HOTSPOT_MARGIN_; const right = left + this.WIDTH_ + 2 * this.HOTSPOT_MARGIN_; return new Blockly.utils.Rect(top, bottom, left, right); } /** * Returns the bounding rectangle of the UI element in pixel units relative to * the Blockly injection div. * * @returns The component’s bounding box. */ getBoundingRectangle(): Blockly.utils.Rect { return new Blockly.utils.Rect( this.top_, this.top_ + this.HEIGHT_, this.left_, this.left_ + this.WIDTH_, ); } /** * Positions the backpack. * It is positioned in the opposite corner to the corner the * categories/toolbox starts at. * * @param metrics The workspace metrics. * @param savedPositions List of rectangles that * are already on the workspace. */ position( metrics: Blockly.MetricsManager.UiMetrics, savedPositions: Blockly.utils.Rect[], ) { if (!this.initialized_) { return; } const scrollbars = this.workspace_.scrollbar; const hasVerticalScrollbars = scrollbars && scrollbars.isVisible() && scrollbars.canScrollVertically(); const hasHorizontalScrollbars = scrollbars && scrollbars.isVisible() && scrollbars.canScrollHorizontally(); if ( metrics.toolboxMetrics.position === Blockly.TOOLBOX_AT_LEFT || (this.workspace_.horizontalLayout && !this.workspace_.RTL) ) { // Right corner placement. this.left_ = metrics.absoluteMetrics.left + metrics.viewMetrics.width - this.WIDTH_ - this.MARGIN_HORIZONTAL_; if (hasVerticalScrollbars && !this.workspace_.RTL) { this.left_ -= Blockly.Scrollbar.scrollbarThickness; } } else { // Left corner placement. this.left_ = this.MARGIN_HORIZONTAL_; if (hasVerticalScrollbars && this.workspace_.RTL) { this.left_ += Blockly.Scrollbar.scrollbarThickness; } } const startAtBottom = metrics.toolboxMetrics.position === Blockly.TOOLBOX_AT_BOTTOM; if (startAtBottom) { // Bottom corner placement this.top_ = metrics.absoluteMetrics.top + metrics.viewMetrics.height - this.HEIGHT_ - this.MARGIN_VERTICAL_; if (hasHorizontalScrollbars) { // The horizontal scrollbars are always positioned on the bottom. this.top_ -= Blockly.Scrollbar.scrollbarThickness; } } else { // Upper corner placement this.top_ = metrics.absoluteMetrics.top + this.MARGIN_VERTICAL_; } // Check for collision and bump if needed. let boundingRect = this.getBoundingRectangle(); for (let i = 0, otherEl; (otherEl = savedPositions[i]); i++) { if (boundingRect.intersects(otherEl)) { if (startAtBottom) { // Bump up. this.top_ = otherEl.top - this.HEIGHT_ - this.MARGIN_VERTICAL_; } else { // Bump down. this.top_ = otherEl.bottom + this.MARGIN_VERTICAL_; } // Recheck other savedPositions boundingRect = this.getBoundingRectangle(); i = -1; } } if (this.svgGroup_) { this.svgGroup_.setAttribute( 'transform', `translate(${this.left_},${this.top_})`, ); } } /** * Returns the count of items in the backpack. * * @returns The count of items. */ getCount(): number { return this.contents_.length; } /** * Returns backpack contents. * * @returns The backpack contents. */ getContents(): string[] { // Return a shallow copy of the contents array. return [...this.contents_]; } /** * Handles when a block or bubble is dropped on this component. * Should not handle delete here. * * @param dragElement The block or bubble currently * being dragged. */ onDrop(dragElement: Blockly.IDraggable) { if (isBackpackable(dragElement)) { this.addBackpackable(dragElement); } } /** * Converts the provided block into a JSON string and * cleans the JSON of any unnecessary attributes * * @param block The block to convert. * @returns The JSON object as a string. */ private blockToJsonString(block: Blockly.Block): string { const json = Blockly.serialization.blocks.save(block); // Add a 'kind' key so the flyout can recognize it as a block. (json as Blockly.utils.toolbox.FlyoutItemInfo).kind = 'BLOCK'; // The keys to remove. const keys = ['id', 'height', 'width', 'pinned', 'enabled']; // Traverse the JSON recursively. const traverseJson = function (json: StateWithIndex, keys: string[]) { for (const key in json) { if (key) { if (keys.indexOf(key) !== -1) { delete json[key]; } if (json[key] && typeof json[key] === 'object') { traverseJson(json[key] as StateWithIndex, keys); } } } }; if (json) { traverseJson(json as StateWithIndex, keys); } return JSON.stringify(json); } /** * Converts serialized XML to its equivalent serialized JSON string * * @param blockXml The XML serialized block. * @returns The JSON object as a string. */ private blockXmlToJsonString(blockXml: string): string { if (!blockXml.startsWith('<block')) { throw new Error('Unrecognized XML format'); } const workspace = new Blockly.Workspace(); try { const block = Blockly.Xml.domToBlock( Blockly.utils.xml.textToDom(blockXml), workspace, ); return this.blockToJsonString(block); } finally { workspace.dispose(); } } /** * Returns whether the backpack contains a duplicate of the provided Block. * * @param block The block to check. * @returns Whether the backpack contains a duplicate of the * provided block. */ containsBlock(block: Blockly.Block): boolean { if (isBackpackable(block)) { return this.containsBackpackable(block); } else { return this.contents_.indexOf(this.blockToJsonString(block)) !== -1; } } /** * Adds the specified block to backpack. * * @param block The block to be added to the backpack. */ addBlock(block: Blockly.Block) { if (isBackpackable(block)) { this.addBackpackable(block); } else { this.addItem(this.blockToJsonString(block)); } } /** * Adds the provided blocks to backpack. * * @param blocks The blocks to be added to the * backpack. */ addBlocks(blocks: Blockly.Block[]) { for (const block of blocks) { if (isBackpackable(block)) { this.addBackpackable(block); } else { this.addItem(this.blockToJsonString(block)); } } } /** * Removes the specified block from the backpack. * * @param block The block to be removed from the backpack. */ removeBlock(block: Blockly.Block) { if (isBackpackable(block)) { this.removeBackpackable(block); } else { this.removeItem(this.blockToJsonString(block)); } } /** * @param backpackable The backpackable we want to check for existence within * the backpack. * @return whether the backpack contains a duplicate of the provided * backpackable. */ containsBackpackable(backpackable: Backpackable): boolean { return backpackable .toFlyoutInfo() .every((info) => this.contents_.indexOf(JSON.stringify(info)) !== -1); } /** * @param backpackable The backpackable to add to the backpack. */ addBackpackable(backpackable: Backpackable) { this.addBackpackables([backpackable]); } /** @param backpackables The backpackables to add to the backpack. */ addBackpackables(backpackables: Backpackable[]) { this.addItems( backpackables .map((b) => b.toFlyoutInfo()) .reduce((acc, curr) => [...acc, ...curr]) .map((info) => JSON.stringify(info)), ); } /** @param backpackable The backpackable to remove from the backpack. */ removeBackpackable(backpackable: Backpackable) { for (const info of backpackable.toFlyoutInfo()) { this.removeItem(JSON.stringify(info)); } } /** * Adds item to backpack. * * @param item Text representing the JSON of a block to add, * cleaned of all unnecessary attributes. */ addItem(item: string) { this.addItems([item]); } /** * Adds multiple items to the backpack. * * @param items The backpack contents to add. */ addItems(items: string[]) { const addedItems = this.filterDuplicates(items); if (addedItems.length) { this.contents_.unshift(...addedItems); this.onContentChange(); } } /** * Removes item from the backpack. * * @param item Text representing the JSON of a block to remove, * cleaned of all unnecessary attributes. */ removeItem(item: string) { const itemIndex = this.contents_.indexOf(item); if (itemIndex !== -1) { this.contents_.splice(itemIndex, 1); this.onContentChange(); } } /** * Sets backpack contents. * * @param contents The new backpack contents. */ setContents(contents: string[]) { this.contents_ = []; this.contents_ = this.filterDuplicates( // Support XML serialized content for backwards compatiblity: // https://github.com/google/blockly-samples/issues/1827 contents.map((content) => content.startsWith('<block') ? this.blockXmlToJsonString(content) : content, ), ); this.onContentChange(); } /** * Empties the backpack's contents. If the contents-flyout is currently open * it will be closed. */ empty() { if (!this.getCount()) { return; } if (this.contents_.length) { this.contents_ = []; this.onContentChange(); } this.close(); } /** * Handles content change. */ protected onContentChange() { this.maybeRefreshFlyoutContents(); Blockly.Events.fire(new BackpackChange(this.workspace_.id)); if (!this.options.useFilledBackpackImage || !this.svgImg_) return; if (this.contents_.length > 0) { this.svgImg_.setAttributeNS( Blockly.utils.dom.XLINK_NS, 'xlink:href', backpackFilledSvgDataUri, ); } else { this.svgImg_.setAttributeNS( Blockly.utils.dom.XLINK_NS, 'xlink:href', backpackSvgDataUri, ); } } /** * Returns a filtered list without duplicates within itself and without any * shared elements with this.contents_. * * @param array The array of items to filter. * @returns The filtered list. */ private filterDuplicates(array: string[]): string[] { return array.filter((item, idx) => { return array.indexOf(item) === idx && this.contents_.indexOf(item) === -1; }); } /** * Returns whether the backpack is open-able. * * @returns Whether the backpack is open-able. */ protected isOpenable(): boolean { return !this.isOpen() && this.options.allowEmptyBackpackOpen ? true : this.getCount() > 0; } /** * Returns whether the backpack is open. * * @returns Whether the backpack is open. */ isOpen(): boolean { return this.flyout_ ? this.flyout_.isVisible() : false; } /** * Opens the backpack flyout. */ open() { if (!this.isOpenable()) { return; } const jsons = this.contents_.map((text) => JSON.parse(text)); this.flyout_?.show(jsons); // TODO: We can remove the setVisible check when updating from ^10.0.0 to // ^11. /* eslint-disable @typescript-eslint/no-explicit-any */ if ( this.workspace_.scrollbar && (this.workspace_.scrollbar as any).setVisible ) { (this.workspace_.scrollbar as any).setVisible(false); } /* eslint-enable @typescript-eslint/no-explicit-any */ Blockly.Events.fire(new BackpackOpen(true, this.workspace_.id)); } /** * Refreshes backpack flyout contents if the flyout is open. */ protected maybeRefreshFlyoutContents() { if (!this.isOpen()) { return; } const json = this.contents_.map((text) => JSON.parse(text)); this.flyout_?.show(json); } /** * Closes the backpack flyout. */ close() { if (!this.isOpen()) { return; } this.flyout_?.hide(); // TODO: We can remove the setVisible check when updating from ^10.0.0 to // ^11. /* eslint-disable @typescript-eslint/no-explicit-any */ if ( this.workspace_.scrollbar && (this.workspace_.scrollbar as any).setVisible ) { (this.workspace_.scrollbar as any).setVisible(true); } /* eslint-enable @typescript-eslint/no-explicit-any */ Blockly.Events.fire(new BackpackOpen(false, this.workspace_.id)); } /** * Hides the component. Called in Blockly.hideChaff. * * @param onlyClosePopups Whether only popups should be closed. * Flyouts should not be closed if this is true. */ autoHide(onlyClosePopups: boolean) { // The backpack flyout always autocloses because it overlays the backpack UI // (no backpack to click to close it). if (!onlyClosePopups) { this.close(); } } /** * Handle click event. * * @param e Mouse event. */ protected onClick(e: Event) { if (e instanceof MouseEvent && Blockly.browserEvents.isRightButton(e)) { return; } this.open(); const uiEvent = new (Blockly.Events.get(Blockly.Events.CLICK))( null, this.workspace_.id, 'backpack', ); Blockly.Events.fire(uiEvent); } /** * Handles when a cursor with a block or bubble enters this drag target. * * @param dragElement The block or bubble currently * being dragged. */ onDragEnter(dragElement: Blockly.IDraggable) { if (isBackpackable(dragElement)) { this.updateHoverStying(true); } } /** * Handles when a cursor with a block or bubble exits this drag target. * * @param dragElement The block or bubble currently * being dragged. */ onDragExit(dragElement: Blockly.IDraggable) { this.updateHoverStying(false); } /** * Handles a mouseover event. */ private onMouseOver() { if (this.isOpenable()) { this.updateHoverStying(true); } } /** * Handles a mouseout event. */ private onMouseOut() { this.updateHoverStying(false); } /** * Adds or removes styling to darken the backpack to show it is interactable. * * @param addClass True to add styling, false to remove. */ protected updateHoverStying(addClass: boolean) { const backpackDarken = 'blocklyBackpackDarken'; if (this.svgImg_) { if (addClass) { Blockly.utils.dom.addClass(this.svgImg_, backpackDarken); } else { Blockly.utils.dom.removeClass(this.svgImg_, backpackDarken); } } } /** * Returns whether the provided block or bubble should not be moved after * being dropped on this component. If true, the element will return to where * it was when the drag started. * * @param dragElement The block or bubble currently * being dragged. * @returns Whether the block or bubble provided should be returned * to drag start. */ shouldPreventMove(dragElement: Blockly.IDraggable): boolean { return dragElement instanceof Blockly.BlockSvg; } /** * Prevents a workspace scroll and click event if the backpack is openable. * * @param e A mouse down event. */ protected blockMouseDownWhenOpenable(e: Event) { if ( e instanceof MouseEvent && !Blockly.browserEvents.isRightButton(e) && this.isOpenable() ) { e.stopPropagation(); // Don't start a workspace scroll. } } } /** * Base64 encoded data uri for backpack icon. */ const backpackSvgDataUri = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC' + '9zdmciIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDI0IDI0IiBoZWlnaHQ9IjI0cHgiIH' + 'ZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0cHgiIGZpbGw9IiM0NTVBNjQiPjxnPjxyZW' + 'N0IGZpbGw9Im5vbmUiIGhlaWdodD0iMjQiIHdpZHRoPSIyNCIvPjwvZz48Zz48Zy8+PGc+PH' + 'BhdGggZD0iTTEzLjk3LDUuMzRDMTMuOTgsNS4yMywxNCw1LjEyLDE0LDVjMC0xLjEtMC45LT' + 'ItMi0ycy0yLDAuOS0yLDJjMCwwLjEyLDAuMDIsMC4yMywwLjAzLDAuMzRDNy42OSw2LjE1LD' + 'YsOC4zOCw2LDExdjggYzAsMS4xLDAuOSwyLDIsMmg4YzEuMSwwLDItMC45LDItMnYtOEMxOC' + 'w4LjM4LDE2LjMxLDYuMTUsMTMuOTcsNS4zNHogTTExLDVjMC0wLjU1LDAuNDUtMSwxLTFzMS' + 'wwLjQ1LDEsMSBjMCwwLjAzLTAuMDEsMC4wNi0wLjAyLDAuMDlDMTIuNjYsNS4wMywxMi4zNC' + 'w1LDEyLDVzLTAuNjYsMC4wMy0wLjk4LDAuMDlDMTEuMDEsNS4wNiwxMSw1LjAzLDExLDV6IE' + '0xNiwxM3YxdjAuNSBjMCwwLjI4LTAuMjIsMC41LTAuNSwwLjVTMTUsMTQuNzgsMTUsMTQuNV' + 'YxNHYtMUg4di0xaDdoMVYxM3oiLz48L2c+PC9nPjwvc3ZnPg=='; /** * Base64 encoded data uri for backpack icon when filled. */ const backpackFilledSvgDataUri = 'data:image/svg+xml;base64,PD94bWwgdmVyc2' + 'lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gQ3JlYX' + 'RlZCB3aXRoIElua3NjYXBlIChodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy8pIC0tPgoKPHN2Zw' + 'ogICB3aWR0aD0iMjQiCiAgIGhlaWdodD0iMjQiCiAgIHZpZXdCb3g9IjAgMCAyNCAyNCIKIC' + 'AgdmVyc2lvbj0iMS4xIgogICBpZD0ic3ZnNSIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3' + 'JnLzIwMDAvc3ZnIgogICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj' + '4KICA8ZGVmcwogICAgIGlkPSJkZWZzMiIgLz4KICA8ZwogICAgIGlkPSJsYXllcjEiPgogIC' + 'AgPGcKICAgICAgIHN0eWxlPSJmaWxsOiM0NTVhNjQiCiAgICAgICBpZD0iZzg0OCIKICAgIC' + 'AgIHRyYW5zZm9ybT0ibWF0cml4KDAuMjY0NTgzMzMsMCwwLDAuMjY0NTgzMzMsOC44MjQ5OT' + 'k3LDguODI0OTk5NykiPgogICAgICA8ZwogICAgICAgICBpZD0iZzgyNiI+CiAgICAgICAgPH' + 'JlY3QKICAgICAgICAgICBmaWxsPSJub25lIgogICAgICAgICAgIGhlaWdodD0iMjQiCiAgIC' + 'AgICAgICAgd2lkdGg9IjI0IgogICAgICAgICAgIGlkPSJyZWN0ODI0IgogICAgICAgICAgIH' + 'g9IjAiCiAgICAgICAgICAgeT0iMCIgLz4KICAgICAgPC9nPgogICAgICA8ZwogICAgICAgIC' + 'BpZD0iZzgzNCI+CiAgICAgICAgPGcKICAgICAgICAgICBpZD0iZzgyOCIgLz4KICAgICAgIC' + 'A8ZwogICAgICAgICAgIGlkPSJnMjIyMyI+CiAgICAgICAgICA8ZwogICAgICAgICAgICAgaW' + 'Q9ImcyMTAxNiI+CiAgICAgICAgICAgIDxnCiAgICAgICAgICAgICAgIHN0eWxlPSJmaWxsOi' + 'M0NTVhNjQiCiAgICAgICAgICAgICAgIGlkPSJnMTQ5MyIKICAgICAgICAgICAgICAgdHJhbn' + 'Nmb3JtPSJtYXRyaXgoMy43Nzk1Mjc2LDAsMCwzLjc3OTUyNzYsLTMzLjM1NDMzLC0zMy4zNT' + 'QzMykiPgogICAgICAgICAgICAgIDxnCiAgICAgICAgICAgICAgICAgaWQ9ImcxNDcxIj4KIC' + 'AgICAgICAgICAgICAgIDxwYXRoCiAgICAgICAgICAgICAgICAgICBpZD0icmVjdDE0NjkiCi' + 'AgICAgICAgICAgICAgICAgICBzdHlsZT0iZmlsbDpub25lIgogICAgICAgICAgICAgICAgIC' + 'AgZD0iTSAwLDAgSCAyNCBWIDI0IEggMCBaIiAvPgogICAgICAgICAgICAgIDwvZz4KICAgIC' + 'AgICAgICAgICA8ZwogICAgICAgICAgICAgICAgIGlkPSJnMTQ3OSI+CiAgICAgICAgICAgIC' + 'AgICA8ZwogICAgICAgICAgICAgICAgICAgaWQ9ImcxNDczIiAvPgogICAgICAgICAgICAgIC' + 'AgPGcKICAgICAgICAgICAgICAgICAgIGlkPSJnMTQ3NyI+CiAgICAgICAgICAgICAgICAgID' + 'xwYXRoCiAgICAgICAgICAgICAgICAgICAgIGlkPSJwYXRoMTQ3NSIKICAgICAgICAgICAgIC' + 'AgICAgICAgZD0ibSAxMiwzIGMgLTEuMSwwIC0yLDAuOSAtMiwyIDAsMC4xMiAwLjAxOTMsMC' + '4yMjk4NDM4IDAuMDI5MywwLjMzOTg0MzggQyA3LjY4OTI5NjUsNi4xNDk4NDMzIDYsOC4zOC' + 'A2LDExIHYgOCBjIDAsMS4xIDAuOSwyIDIsMiBoIDggYyAxLjEsMCAyLC0wLjkgMiwtMiBWID' + 'ExIEMgMTgsOC4zOCAxNi4zMTA3MDMsNi4xNDk4NDMzIDEzLjk3MDcwMyw1LjMzOTg0MzggMT' + 'MuOTgwNzAzLDUuMjI5ODQzOCAxNCw1LjEyIDE0LDUgMTQsMy45IDEzLjEsMyAxMiwzIFogbS' + 'AwLDEgYyAwLjU1LDAgMSwwLjQ1IDEsMSAwLDAuMDMgLTAuMDA5NSwwLjA1OTg0NCAtMC4wMT' + 'k1MywwLjA4OTg0NCBDIDEyLjY2MDQ2OSw1LjAyOTg0MzggMTIuMzQsNSAxMiw1IDExLjY2LD' + 'UgMTEuMzM5NTMxLDUuMDI5ODQzOCAxMS4wMTk1MzEsNS4wODk4NDM4IDExLjAwOTUzMSw1Lj' + 'A1OTg0MzggMTEsNS4wMyAxMSw1IDExLDQuNDUgMTEuNDUsNCAxMiw0IFogbSAtMy40NzI2NT' + 'YyLDYuMzk4NDM4IGggMS4xNTYyNSB2IDIuNjQwNjI0IGggMC4zMDkzMzU0IGwgLTIuMzdlLT' + 'UsLTEuMTcxMTQ2IDEuMDgyNzEwNSwtMTBlLTcgMC4wMTEsMS4xNzExNDYgaCAwLjMzMzMwNC' + 'BsIC0wLjAzNTA0LC0yLjU4NzMxNSBoIDAuNTc4MTI1IDAuNTc4MTI1IGwgMC4wMTEwNSwyLj' + 'U4NzMxNSBoIDAuMzU2MDI0IFYgMTIuMDYwNTQ3IEggMTQuMDYyNSB2IDAuOTc4NTE1IGggMC' + '4zMzAwNzggdiAtMi41NTI3MzQgaCAxLjE1NjI1IHYgMi41NTI3MzQgaCAwLjk2Njc5NyB2ID' + 'AuMzU3NDIyIEggOS42ODM1OTM4IDguNTI3MzQzOCA3LjYwMzUxNTYgdiAtMC4zNTc0MjIgaC' + 'AwLjkyMzgyODIgeiIKICAgICAgICAgICAgICAgICAgICAgLz4KICAgICAgICAgICAgICAgID' + 'wvZz4KICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgIDwvZz4KICAgICAgICAgIDwvZz' + '4KICAgICAgICA8L2c+CiAgICAgIDwvZz4KICAgIDwvZz4KICA8L2c+Cjwvc3ZnPgo='; Blockly.Css.register(` .blocklyBackpack { opacity: 0.4; } .blocklyBackpackDarken { opacity: 0.6; } .blocklyBackpack:active { opacity: 0.8; } `); /** * Custom serializer so that the backpack can save and later recall which * blocks have been saved in a workspace. */ class BackpackSerializer { /** * The priority for deserializing block suggestion data. * Should be after blocks, procedures, and variables. */ priority = Blockly.serialization.priorities.BLOCKS - 10; /** * Saves a target workspace's state to serialized JSON. * * @param workspace the workspace to save * @returns the serialized JSON if present */ save(workspace: Blockly.WorkspaceSvg): object[] { const componentManager = workspace.getComponentManager(); const backpack = componentManager.getComponent('backpack') as Backpack; return backpack?.getContents().map((text) => JSON.parse(text)); } /** * Loads a serialized state into the target workspace. * * @param state the serialized state JSON * @param workspace the workspace to load into */ load(state: object[], workspace: Blockly.WorkspaceSvg) { const jsonStrings = state.map((j) => JSON.stringify(j)); const componentManager = workspace.getComponentManager(); const backpack = componentManager.getComponent('backpack') as Backpack; backpack?.setContents(jsonStrings); } /** * Resets the state of a workspace. * * @param workspace the workspace to reset */ clear(workspace: Blockly.WorkspaceSvg) { const componentManager = workspace.getComponentManager(); const backpack = componentManager.getComponent('backpack') as Backpack; backpack?.empty(); } } interface StateWithIndex extends Blockly.serialization.blocks.State { [key: string]: unknown; }