diff --git a/src/checkable_continuous_flyout.js b/src/checkable_continuous_flyout.js index 81a83fdd96..741d83cf1c 100644 --- a/src/checkable_continuous_flyout.js +++ b/src/checkable_continuous_flyout.js @@ -7,6 +7,7 @@ import * as Blockly from "blockly/core"; import { ContinuousFlyout } from "@blockly/continuous-toolbox"; import { RecyclableBlockFlyoutInflater } from "./recyclable_block_flyout_inflater.js"; +import { StatusIndicatorLabel } from "./status_indicator_label.js"; export class CheckableContinuousFlyout extends ContinuousFlyout { /** @@ -97,7 +98,7 @@ export class CheckableContinuousFlyout extends ContinuousFlyout { const categoryLabels = this.getContents() .filter( (item) => - item.type === "label" && + (item.type === "label" || item.type === "status_indicator_label") && item.element.isLabel() && this.getParentToolbox_().getCategoryByName( item.element.getButtonText() @@ -123,4 +124,15 @@ export class CheckableContinuousFlyout extends ContinuousFlyout { // updated for the new flyout API. Blockly.VerticalFlyout.prototype.layout_.call(this, contents); } + + /** + * Updates the state of status indicators for hardware-based extensions. + */ + refreshStatusButtons() { + for (const item of this.contents) { + if (item.element instanceof StatusIndicatorLabel) { + item.element.refreshStatus(); + } + } + } } diff --git a/src/index.js b/src/index.js index 5f768c9b34..d259ff00ee 100644 --- a/src/index.js +++ b/src/index.js @@ -33,7 +33,6 @@ import { import { CheckableContinuousFlyout } from "./checkable_continuous_flyout.js"; import { buildGlowFilter, glowStack } from "./glows.js"; import { ScratchContinuousToolbox } from "./scratch_continuous_toolbox.js"; -import "./scratch_continuous_category.js"; import "./scratch_comment_icon.js"; import "./scratch_dragger.js"; import "./scratch_variable_map.js"; @@ -62,6 +61,8 @@ 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"; +import { registerStatusIndicatorLabelFlyoutInflater } from "./status_indicator_label_flyout_inflater.js"; +import { registerScratchContinuousCategory } from "./scratch_continuous_category.js"; export * from "blockly/core"; export * from "./block_reporting.js"; @@ -76,6 +77,10 @@ export { ScratchVariables }; export { contextMenuItems }; export { FieldColourSlider, FieldNote }; export { CheckboxBubble } from "./checkbox_bubble.js"; +export { + StatusIndicatorLabel, + StatusButtonState, +} from "./status_indicator_label.js"; export function inject(container, options) { registerFieldAngle(); @@ -89,6 +94,8 @@ export function inject(container, options) { registerFieldVariable(); registerFieldVerticalSeparator(); registerRecyclableBlockFlyoutInflater(); + registerStatusIndicatorLabelFlyoutInflater(); + registerScratchContinuousCategory(); Object.assign(options, { renderer: "scratch", diff --git a/src/scratch_continuous_category.js b/src/scratch_continuous_category.js index 0cbfa12e66..8406014357 100644 --- a/src/scratch_continuous_category.js +++ b/src/scratch_continuous_category.js @@ -1,7 +1,39 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import * as Blockly from "blockly/core"; import { ContinuousCategory } from "@blockly/continuous-toolbox"; -class ScratchContinuousCategory extends ContinuousCategory { +export class ScratchContinuousCategory extends ContinuousCategory { + /** + * Whether this toolbox category has a status indicator button on its label + * in the flyout, typically for extensions that interface with hardware + * devices. + * @type {boolean} + */ + showStatusButton = false; + + /** Creates a new ScratchContinuousCategory. + * + * @param {!Blockly.toolbox.CategoryInfo} toolboxItemDef A toolbox item + * definition. + * @param {!Blockly.Toolbox} parentToolbox The toolbox this category is being + * added to. + * @param {?Blockly.ICollapsibleToolboxItem} opt_parent The parent toolbox + * category, if any. + */ + constructor(toolboxItemDef, parentToolbox, opt_parent) { + super(toolboxItemDef, parentToolbox, opt_parent); + this.showStatusButton = toolboxItemDef["showStatusButton"] === "true"; + } + + /** + * Creates a DOM element for this category's icon. + * @returns {!HTMLElement} A DOM element for this category's icon. + */ createIconDom_() { if (this.toolboxItemDef_.iconURI) { const icon = document.createElement("img"); @@ -15,16 +47,34 @@ class ScratchContinuousCategory extends ContinuousCategory { } } + /** + * Sets whether or not this category is selected. + * @param {boolean} isSelected True if this category is selected. + */ setSelected(isSelected) { super.setSelected(isSelected); // Prevent hardcoding the background color to grey. this.rowDiv_.style.backgroundColor = ""; } + + /** + * Returns whether or not this category's label in the flyout should display + * status indicators. + */ + shouldShowStatusButton() { + return this.showStatusButton; + } } -Blockly.registry.register( - Blockly.registry.Type.TOOLBOX_ITEM, - Blockly.ToolboxCategory.registrationName, - ScratchContinuousCategory, - true -); +/** Registers this toolbox category and unregisters the default one. */ +export function registerScratchContinuousCategory() { + Blockly.registry.unregister( + Blockly.registry.Type.TOOLBOX_ITEM, + ScratchContinuousCategory.registrationName + ); + Blockly.registry.register( + Blockly.registry.Type.TOOLBOX_ITEM, + ScratchContinuousCategory.registrationName, + ScratchContinuousCategory + ); +} diff --git a/src/scratch_continuous_toolbox.js b/src/scratch_continuous_toolbox.js index cfeceabfda..c613eea3ad 100644 --- a/src/scratch_continuous_toolbox.js +++ b/src/scratch_continuous_toolbox.js @@ -1,5 +1,12 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import * as Blockly from "blockly/core"; import { ContinuousToolbox } from "@blockly/continuous-toolbox"; +import { ScratchContinuousCategory } from "./scratch_continuous_category.js"; export class ScratchContinuousToolbox extends ContinuousToolbox { postRenderCallbacks = []; @@ -8,6 +15,49 @@ export class ScratchContinuousToolbox extends ContinuousToolbox { // Intentionally a no-op, Scratch manually manages refreshing the toolbox via forceRerender(). } + /** + * Gets the contents that should be shown in the flyout. + * @returns {!Blockly.utils.toolbox.FlyoutItemInfoArray} Flyout contents. + */ + getInitialFlyoutContents_() { + // TODO(#211) Clean this up when the continuous toolbox plugin is updated. + /** @type {!Blockly.utils.toolbox.FlyoutItemInfoArray} */ + let contents = []; + for (const toolboxItem of this.getToolboxItems()) { + if (toolboxItem instanceof ScratchContinuousCategory) { + if (toolboxItem.shouldShowStatusButton()) { + contents.push({ + kind: "STATUS_INDICATOR_LABEL", + id: toolboxItem.getId(), + text: toolboxItem.getName(), + }); + } else { + // Create a label node to go at the top of the category + contents.push({ kind: "LABEL", text: toolboxItem.getName() }); + } + /** + * @type {string|Blockly.utils.toolbox.FlyoutItemInfoArray| + * Blockly.utils.toolbox.FlyoutItemInfo} + */ + let itemContents = toolboxItem.getContents(); + + // Handle custom categories (e.g. variables and functions) + if (typeof itemContents === "string") { + itemContents = + /** @type {!Blockly.utils.toolbox.DynamicCategoryInfo} */ ({ + custom: itemContents, + kind: "CATEGORY", + }); + } + contents = contents.concat(itemContents); + } + } + return contents; + } + + /** + * Forcibly rerenders the toolbox, preserving selection when possible. + */ forceRerender() { const selectedCategoryName = this.selectedItem_?.getName(); super.refreshSelection(); @@ -18,6 +68,10 @@ export class ScratchContinuousToolbox extends ContinuousToolbox { this.selectCategoryByName(selectedCategoryName); } + /** + * Runs the specified callback after the next rerender. + * @param {!Function} A callback to run whenever the toolbox next rerenders. + */ runAfterRerender(callback) { this.postRenderCallbacks.push(callback); } diff --git a/src/status_indicator_label.js b/src/status_indicator_label.js new file mode 100644 index 0000000000..b09dfb37e8 --- /dev/null +++ b/src/status_indicator_label.js @@ -0,0 +1,186 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2018 Google Inc. + * https://developers.google.com/blockly/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Class for a category header in the flyout for Scratch + * extensions which can display a textual label and a status button. + * @author ericr@media.mit.edu (Eric Rosenbaum) + */ + +import * as Blockly from "blockly/core"; + +/** + * Class for a category header in the flyout for Scratch extensions which can + * display a textual label and a status button. + */ +export class StatusIndicatorLabel extends Blockly.FlyoutButton { + /** + * The ID of the Scratch extension whose status is indicated by this label. + * @type {string} + */ + extensionId; + + /** + * DOM element that displays the status indicator dot. + * @type {!SVGImageElement} + */ + imageElement; + + /** + * Opaque data for mouse up listener used to unbind it in dispose(). + * @type {!Blockly.browserEvents.Data} + */ + mouseUpwrapper; + + /** + * Function to be invoked when the status indicator is clicked. + * @type {?Function} + */ + static statusButtonCallback; + + /** + * Creates a new StatusIndicatorLabel. + * + * @param {!Blockly.WorkspaceSvg} workspace The workspace in which to place + * this header. + * @param {!Blockly.WorkspaceSvg} targetWorkspace The flyout's target + * workspace. + * @param {!Element} xml The XML specifying the header. + */ + constructor(workspace, targetWorkspace, json, isFlyoutLabel) { + super(workspace, targetWorkspace, json, isFlyoutLabel); + /** + * @type {string} + */ + this.extensionId = json["id"]; + + const heightDelta = 40 - this.height; + this.height = 40; + const text = this.getSvgRoot().querySelector("text"); + const previousY = Number(text.getAttribute("y")); + + text.setAttribute("y", previousY + heightDelta / 2); + + const statusButtonWidth = 30; + const marginX = 20; + const marginY = 5; + const touchPadding = 16; + const flyoutWidth = targetWorkspace.getFlyout().getWidth(); + + const statusButtonX = workspace.RTL + ? marginX - flyoutWidth + statusButtonWidth + : (flyoutWidth - statusButtonWidth - marginX) / workspace.scale; + + /** @type {SVGElement} */ + this.imageElement = Blockly.utils.dom.createSvgElement( + "image", + { + class: "blocklyFlyoutButton", + height: statusButtonWidth + "px", + width: statusButtonWidth + "px", + x: statusButtonX + "px", + y: marginY + "px", + }, + this.getSvgRoot() + ); + const imageElementBackground = Blockly.utils.dom.createSvgElement( + "rect", + { + class: "blocklyTouchTargetBackground", + height: statusButtonWidth + 2 * touchPadding + "px", + width: statusButtonWidth + 2 * touchPadding + "px", + x: statusButtonX - touchPadding + "px", + y: marginY - touchPadding + "px", + }, + this.getSvgRoot() + ); + + this.refreshStatus(); + + this.mouseUpWrapper = Blockly.browserEvents.bind( + imageElementBackground, + "mouseup", + null, + () => { + StatusIndicatorLabel.statusButtonCallback?.call(this, this.extensionId); + } + ); + } + + /** + * Set the image on the status button using a status string. + */ + refreshStatus() { + var status = this.getExtensionState(this.extensionId); + var basePath = Blockly.getMainWorkspace().options.pathToMedia; + if (status == StatusButtonState.READY) { + this.setImageSrc(basePath + "status-ready.svg"); + } + if (status == StatusButtonState.NOT_READY) { + this.setImageSrc(basePath + "status-not-ready.svg"); + } + } + + /** + * Set the source URL of the image for the button. + * @param {?string} src New source. + * @package + */ + setImageSrc(src) { + if (src === null) { + // No change if null. + return; + } + this.imageSrc = src; + if (this.imageElement) { + this.imageElement.setAttributeNS( + "http://www.w3.org/1999/xlink", + "xlink:href", + this.imageSrc || "" + ); + } + } + + /** + * Gets the extension state. Overridden externally. + * @param {string} extensionId The ID of the extension in question. + * @return {Blockly.StatusButtonState} The state of the extension. + * @public + */ + getExtensionState(extensionId) { + return StatusButtonState.NOT_READY; + } + + /** + * Disposes of this status indicator label. + */ + dispose() { + Blockly.browserEvents.unbind(this.mouseUpWrapper); + super.dispose(); + } +} + +/** + * Set of available states for a status indicator. + */ +export const StatusButtonState = { + READY: "ready", + NOT_READY: "not ready", +}; diff --git a/src/status_indicator_label_flyout_inflater.js b/src/status_indicator_label_flyout_inflater.js new file mode 100644 index 0000000000..2ae48bc135 --- /dev/null +++ b/src/status_indicator_label_flyout_inflater.js @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { StatusIndicatorLabel } from "./status_indicator_label.js"; + +/** + * Flyout inflater responsible for creating status indicator labels. + */ +class StatusIndicatorLabelFlyoutInflater extends Blockly.LabelFlyoutInflater { + /** + * Creates a status indicator label on the flyout from the given state. + * @param {!Object} state JSON representation of a status indicator label. + * @param {!Blockly.WorkspaceSvg} flyoutWorkspace The workspace to create the + * label on. + * @returns {!StatusIndicatorLabel} The newly created status indicator label. + */ + load(state, flyoutWorkspace) { + const label = new StatusIndicatorLabel( + flyoutWorkspace, + flyoutWorkspace.targetWorkspace, + state, + true + ); + label.show(); + return label; + } +} + +/** + * Register the status indicator label flyout inflater. + */ +export function registerStatusIndicatorLabelFlyoutInflater() { + Blockly.registry.register( + Blockly.registry.Type.FLYOUT_INFLATER, + "status_indicator_label", + StatusIndicatorLabelFlyoutInflater + ); +}