@blockly/workspace-backpack
Version:
A Blockly plugin that adds Backpack support.
1,040 lines (948 loc) • 31.5 kB
text/typescript
/**
* @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;
}