diff --git a/core/flyout_extension_category_header.js b/core/flyout_extension_category_header.js deleted file mode 100644 index b97aa04b55..0000000000 --- a/core/flyout_extension_category_header.js +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @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) - */ -'use strict'; - -goog.provide('Blockly.FlyoutExtensionCategoryHeader'); - -goog.require('Blockly.FlyoutButton'); - -/** - * Class for a category header in the flyout for Scratch extensions which can - * display a textual label and a status button. - * @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. - * @extends {Blockly.FlyoutButton} - * @constructor - */ -Blockly.FlyoutExtensionCategoryHeader = function(workspace, targetWorkspace, xml) { - - this.init(workspace, targetWorkspace, xml, false); - - /** - * @type {number} - * @private - */ - this.flyoutWidth_ = this.targetWorkspace_.getFlyout().getWidth(); - - /** - * @type {string} - */ - this.extensionId = xml.getAttribute('id'); - - /** - * Whether this is a label at the top of a category. - * @type {boolean} - * @private - */ - this.isCategoryLabel_ = true; -}; -goog.inherits(Blockly.FlyoutExtensionCategoryHeader, Blockly.FlyoutButton); - -/** - * Create the label and button elements. - * @return {!Element} The SVG group. - */ -Blockly.FlyoutExtensionCategoryHeader.prototype.createDom = function() { - var cssClass = 'blocklyFlyoutLabel'; - - this.svgGroup_ = Blockly.utils.createSvgElement('g', {'class': cssClass}, - this.workspace_.getCanvas()); - - this.addTextSvg(true); - - this.refreshStatus(); - - var statusButtonWidth = 30; - var marginX = 20; - var marginY = 5; - var touchPadding = 16; - - var statusButtonX = this.workspace_.RTL ? (marginX - this.flyoutWidth_ + statusButtonWidth) : - (this.flyoutWidth_ - statusButtonWidth - marginX) / this.workspace_.scale; - - if (this.imageSrc_) { - /** @type {SVGElement} */ - this.imageElement_ = Blockly.utils.createSvgElement( - 'image', - { - 'class': 'blocklyFlyoutButton', - 'height': statusButtonWidth + 'px', - 'width': statusButtonWidth + 'px', - 'x': statusButtonX + 'px', - 'y': marginY + 'px' - }, - this.svgGroup_); - this.imageElementBackground_ = Blockly.utils.createSvgElement( - 'rect', - { - 'class': 'blocklyTouchTargetBackground', - 'height': statusButtonWidth + 2 * touchPadding + 'px', - 'width': statusButtonWidth + 2 * touchPadding + 'px', - 'x': (statusButtonX - touchPadding) + 'px', - 'y': (marginY - touchPadding) + 'px' - }, - this.svgGroup_); - this.setImageSrc(this.imageSrc_); - } - - this.callback_ = Blockly.statusButtonCallback.bind(this, this.extensionId); - - this.mouseUpWrapper_ = Blockly.bindEventWithChecks_(this.imageElementBackground_, 'mouseup', - this, this.onMouseUp_); - return this.svgGroup_; -}; - -/** - * Set the image on the status button using a status string. - */ -Blockly.FlyoutExtensionCategoryHeader.prototype.refreshStatus = function() { - var status = Blockly.FlyoutExtensionCategoryHeader.getExtensionState(this.extensionId); - var basePath = Blockly.mainWorkspace.options.pathToMedia; - if (status == Blockly.StatusButtonState.READY) { - this.setImageSrc(basePath + 'status-ready.svg'); - } - if (status == Blockly.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 - */ -Blockly.FlyoutExtensionCategoryHeader.prototype.setImageSrc = function(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 - */ -Blockly.FlyoutExtensionCategoryHeader.getExtensionState = function(/* extensionId */) { - return Blockly.StatusButtonState.NOT_READY; -}; diff --git a/src/blocks/vertical_extensions.js b/src/blocks/vertical_extensions.js index 406d036f2d..4845fabc67 100644 --- a/src/blocks/vertical_extensions.js +++ b/src/blocks/vertical_extensions.js @@ -28,6 +28,7 @@ import * as Blockly from "blockly/core"; import { ScratchProcedures } from "../procedures.js"; import * as Constants from "../constants.js"; +import { CheckboxIcon } from "../checkbox_icon.js"; const VerticalExtensions = {}; /** @@ -149,6 +150,7 @@ VerticalExtensions.OUTPUT_BOOLEAN = function () { * flyout to toggle display of their current value in a chip on the stage. */ VerticalExtensions.MONITOR_BLOCK = function () { + this.addIcon(new CheckboxIcon(this)); this.checkboxInFlyout = true; }; diff --git a/src/checkable_continuous_flyout.js b/src/checkable_continuous_flyout.js index 9d75cd53be..1e4944b264 100644 --- a/src/checkable_continuous_flyout.js +++ b/src/checkable_continuous_flyout.js @@ -1,90 +1,28 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import * as Blockly from "blockly/core"; import { ContinuousFlyout } from "@blockly/continuous-toolbox"; +import { RecyclableBlockFlyoutInflater } from "./recyclable_block_flyout_inflater.js"; export class CheckableContinuousFlyout extends ContinuousFlyout { - /** - * Size of a checkbox next to a variable reporter. - * @type {number} - * @const - */ - static CHECKBOX_SIZE = 25; - - /** - * Amount of touchable padding around reporter checkboxes. - * @type {number} - * @const - */ - static CHECKBOX_TOUCH_PADDING = 12; - - /** - * SVG path data for checkmark in checkbox. - * @type {string} - * @const - */ - static CHECKMARK_PATH = - "M" + - CheckableContinuousFlyout.CHECKBOX_SIZE / 4 + - " " + - CheckableContinuousFlyout.CHECKBOX_SIZE / 2 + - "L" + - (5 * CheckableContinuousFlyout.CHECKBOX_SIZE) / 12 + - " " + - (2 * CheckableContinuousFlyout.CHECKBOX_SIZE) / 3 + - "L" + - (3 * CheckableContinuousFlyout.CHECKBOX_SIZE) / 4 + - " " + - CheckableContinuousFlyout.CHECKBOX_SIZE / 3; - - /** - * Size of the checkbox corner radius - * @type {number} - * @const - */ - static CHECKBOX_CORNER_RADIUS = 5; - - /** - * @type {number} - * @const - */ - static CHECKBOX_MARGIN = ContinuousFlyout.prototype.MARGIN; - - /** - * Total additional width of a row that contains a checkbox. - * @type {number} - * @const - */ - static CHECKBOX_SPACE_X = - CheckableContinuousFlyout.CHECKBOX_SIZE + - 2 * CheckableContinuousFlyout.CHECKBOX_MARGIN; - constructor(workspaceOptions) { workspaceOptions.modalInputs = false; super(workspaceOptions); this.tabWidth_ = -2; this.MARGIN = 12; this.GAP_Y = 12; - CheckableContinuousFlyout.CHECKBOX_MARGIN = this.MARGIN; - - /** - * Map of checkboxes that correspond to monitored blocks. - * Each element is an object containing the SVG for the checkbox, a boolean - * for its checked state, and the block the checkbox is associated with. - * @type {!Object.} - * @private - */ - this.checkboxes_ = new Map(); - } - - initFlyoutButton_(button, x, y) { - if (button.isLabel()) { - button.height = 40; - } - super.initFlyoutButton_(button, x, y); } show(flyoutDef) { - this.clearOldCheckboxes(); super.show(flyoutDef); + const inflater = this.getInflaterForType("block"); + if (inflater instanceof RecyclableBlockFlyoutInflater) { + inflater.emptyRecycledBlocks(); + } } serializeBlock(block) { @@ -97,158 +35,6 @@ export class CheckableContinuousFlyout extends ContinuousFlyout { return json; } - clearOldCheckboxes() { - for (const checkbox of this.checkboxes_.values()) { - checkbox.svgRoot.remove(); - } - this.checkboxes_.clear(); - } - - layout_(contents, gaps) { - super.layout_(contents, gaps); - // We want large gaps between categories (see GAP_Y), but don't want those - // counted as part of the category for purposes of scrolling to show the - // category, so we reset/adjust the label gaps used for the scroll position - // calculation here. - this.labelGaps.fill( - this.getWorkspace().getRenderer().getConstants().GRID_UNIT - ); - } - - calculateBottomPadding(contentMetrics, viewMetrics) { - // Since we're messing with the alignment by munging the label gaps, we also - // need to adjust the bottom padding. - return ( - super.calculateBottomPadding(contentMetrics, viewMetrics) - - this.getWorkspace().getRenderer().getConstants().GRID_UNIT * 4 - ); - } - - addBlockListeners_(root, block, rect) { - if (block.checkboxInFlyout) { - const coordinates = block.getRelativeToSurfaceXY(); - const checkbox = this.createCheckbox_( - block, - coordinates.x, - coordinates.y, - block.getHeightWidth() - ); - let moveX = coordinates.x; - if (this.RTL) { - moveX -= - CheckableContinuousFlyout.CHECKBOX_SIZE + - CheckableContinuousFlyout.CHECKBOX_MARGIN; - } else { - moveX += - CheckableContinuousFlyout.CHECKBOX_SIZE + - CheckableContinuousFlyout.CHECKBOX_MARGIN; - } - block.moveBy(moveX, 0); - this.listeners.push( - Blockly.browserEvents.bind( - checkbox.svgRoot, - "mousedown", - null, - this.checkboxClicked_(checkbox) - ) - ); - } - super.addBlockListeners_(root, block, rect); - } - - /** - * Respond to a click on a checkbox in the flyout. - * @param {!Object} checkboxObj An object containing the svg element of the - * checkbox, a boolean for the state of the checkbox, and the block the - * checkbox is associated with. - * @return {!Function} Function to call when checkbox is clicked. - * @private - */ - checkboxClicked_(checkboxObj) { - return function (e) { - this.setCheckboxState(checkboxObj.block.id, !checkboxObj.clicked); - // This event has been handled. No need to bubble up to the document. - e.stopPropagation(); - e.preventDefault(); - }.bind(this); - } - - /** - * Create and place a checkbox corresponding to the given block. - * @param {!Blockly.Block} block The block to associate the checkbox to. - * @param {number} cursorX The x position of the cursor during this layout pass. - * @param {number} cursorY The y position of the cursor during this layout pass. - * @param {!{height: number, width: number}} blockHW The height and width of the - * block. - * @private - */ - createCheckbox_(block, cursorX, cursorY, blockHW) { - var checkboxState = this.getCheckboxState(block.id); - var svgRoot = block.getSvgRoot(); - var extraSpace = - CheckableContinuousFlyout.CHECKBOX_SIZE + - CheckableContinuousFlyout.CHECKBOX_MARGIN; - var xOffset = this.RTL - ? this.getWidth() / this.workspace_.scale - extraSpace - : cursorX; - var yOffset = - cursorY + - blockHW.height / 2 - - CheckableContinuousFlyout.CHECKBOX_SIZE / 2; - var touchMargin = CheckableContinuousFlyout.CHECKBOX_TOUCH_PADDING; - var checkboxGroup = Blockly.utils.dom.createSvgElement( - "g", - { - transform: `translate(${xOffset}, ${yOffset})`, - fill: "transparent", - }, - null - ); - Blockly.utils.dom.createSvgElement( - "rect", - { - class: "blocklyFlyoutCheckbox", - height: CheckableContinuousFlyout.CHECKBOX_SIZE, - width: CheckableContinuousFlyout.CHECKBOX_SIZE, - rx: CheckableContinuousFlyout.CHECKBOX_CORNER_RADIUS, - ry: CheckableContinuousFlyout.CHECKBOX_CORNER_RADIUS, - }, - checkboxGroup - ); - Blockly.utils.dom.createSvgElement( - "path", - { - class: "blocklyFlyoutCheckboxPath", - d: CheckableContinuousFlyout.CHECKMARK_PATH, - }, - checkboxGroup - ); - Blockly.utils.dom.createSvgElement( - "rect", - { - class: "blocklyTouchTargetBackground", - x: -touchMargin + "px", - y: -touchMargin + "px", - height: CheckableContinuousFlyout.CHECKBOX_SIZE + 2 * touchMargin, - width: CheckableContinuousFlyout.CHECKBOX_SIZE + 2 * touchMargin, - }, - checkboxGroup - ); - var checkboxObj = { - svgRoot: checkboxGroup, - clicked: checkboxState, - block: block, - }; - - if (checkboxState) { - Blockly.utils.dom.addClass(checkboxObj.svgRoot, "checked"); - } - - this.workspace_.getCanvas().insertBefore(checkboxGroup, svgRoot); - this.checkboxes_.set(block.id, checkboxObj); - return checkboxObj; - } - /** * Set the state of a checkbox by block ID. * @param {string} blockId ID of the block whose checkbox should be set @@ -256,40 +42,10 @@ export class CheckableContinuousFlyout extends ContinuousFlyout { * @public */ setCheckboxState(blockId, value) { - var checkboxObj = this.checkboxes_.get(blockId); - if (!checkboxObj || checkboxObj.clicked === value) { - return; - } - - var oldValue = checkboxObj.clicked; - checkboxObj.clicked = value; - - if (checkboxObj.clicked) { - Blockly.utils.dom.addClass(checkboxObj.svgRoot, "checked"); - } else { - Blockly.utils.dom.removeClass(checkboxObj.svgRoot, "checked"); - } - - Blockly.Events.fire( - new Blockly.Events.BlockChange( - checkboxObj.block, - "checkbox", - null, - oldValue, - value - ) - ); - } - - /** - * Gets the checkbox state for a block - * @param {string} blockId The ID of the block in question. - * @return {boolean} Whether the block is checked. - * @public - */ - getCheckboxState() { - // Patched by scratch-gui in src/lib/blocks.js. - return false; + this.getWorkspace() + .getBlockById(blockId) + ?.getIcon("checkbox") + ?.setChecked(value); } getFlyoutScale() { @@ -300,11 +56,45 @@ export class CheckableContinuousFlyout extends ContinuousFlyout { return 250; } - blockIsRecyclable_(block) { - const recyclable = super.blockIsRecyclable_(block); - // Exclude blocks with output connections, because they are able to report their current - // value in a popover and recycling them interacts poorly with the VM's maintenance of its - // state. - return recyclable && !block.outputConnection; + setRecyclingEnabled(enabled) { + const inflater = this.getInflaterForType("block"); + if (inflater instanceof RecyclableBlockFlyoutInflater) { + inflater.setRecyclingEnabled(enabled); + } + } + + /** + * Records scroll position for each category in the toolbox. + * The scroll position is determined by the coordinates of each category's + * label after the entire flyout has been rendered. + * @package + */ + recordScrollPositions() { + this.scrollPositions = []; + const categoryLabels = this.getContents() + .filter( + (item) => + (item.type === "label" || item.type === "status_label") && + item.element.isLabel() && + this.getParentToolbox_().getCategoryByName( + item.element.getButtonText() + ) + ) + .map((item) => item.element); + for (const [index, button] of categoryLabels.entries()) { + const position = button.getPosition(); + const adjustedPosition = new Blockly.utils.Coordinate( + position.x, + position.y + ); + this.scrollPositions.push({ + name: button.getButtonText(), + position: adjustedPosition, + }); + } + } + + layout_(contents) { + Blockly.VerticalFlyout.prototype.layout_.call(this, contents); } } diff --git a/src/checkbox_bubble.js b/src/checkbox_bubble.js new file mode 100644 index 0000000000..f4e3eef886 --- /dev/null +++ b/src/checkbox_bubble.js @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; + +/** + * A checkbox shown next to reporter blocks in the flyout. + * @implements {IBubble} + * @implements {IRenderedElement} + */ +export class CheckboxBubble { + /** + * Size of a checkbox next to a variable reporter. + * @type {number} + * @const + */ + static CHECKBOX_SIZE = 25; + + /** + * Amount of touchable padding around reporter checkboxes. + * @type {number} + * @const + */ + static CHECKBOX_TOUCH_PADDING = 12; + + /** + * SVG path data for checkmark in checkbox. + * @type {string} + * @const + */ + static CHECKMARK_PATH = + "M" + + CheckboxBubble.CHECKBOX_SIZE / 4 + + " " + + CheckboxBubble.CHECKBOX_SIZE / 2 + + "L" + + (5 * CheckboxBubble.CHECKBOX_SIZE) / 12 + + " " + + (2 * CheckboxBubble.CHECKBOX_SIZE) / 3 + + "L" + + (3 * CheckboxBubble.CHECKBOX_SIZE) / 4 + + " " + + CheckboxBubble.CHECKBOX_SIZE / 3; + + /** + * Size of the checkbox corner radius + * @type {number} + * @const + */ + static CHECKBOX_CORNER_RADIUS = 5; + + /** + * @type {number} + * @const + */ + static CHECKBOX_MARGIN = 12; + + /** + * Total additional width of a row that contains a checkbox. + * @type {number} + * @const + */ + static CHECKBOX_SPACE_X = + CheckboxBubble.CHECKBOX_SIZE + 2 * CheckboxBubble.CHECKBOX_MARGIN; + + svgRoot; + clickListener; + checked = false; + location = new Blockly.utils.Coordinate(0, 0); + + constructor(sourceBlock) { + this.sourceBlock = sourceBlock; + this.svgRoot = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.G, + {}, + this.sourceBlock.workspace.getBubbleCanvas() + ); + + const touchMargin = CheckboxBubble.CHECKBOX_TOUCH_PADDING; + const checkboxGroup = Blockly.utils.dom.createSvgElement( + "g", + { + fill: "transparent", + }, + null + ); + Blockly.utils.dom.createSvgElement( + "rect", + { + class: "blocklyFlyoutCheckbox", + height: CheckboxBubble.CHECKBOX_SIZE, + width: CheckboxBubble.CHECKBOX_SIZE, + rx: CheckboxBubble.CHECKBOX_CORNER_RADIUS, + ry: CheckboxBubble.CHECKBOX_CORNER_RADIUS, + }, + checkboxGroup + ); + Blockly.utils.dom.createSvgElement( + "path", + { + class: "blocklyFlyoutCheckboxPath", + d: CheckboxBubble.CHECKMARK_PATH, + }, + checkboxGroup + ); + Blockly.utils.dom.createSvgElement( + "rect", + { + class: "blocklyTouchTargetBackground", + x: -touchMargin + "px", + y: -touchMargin + "px", + height: CheckboxBubble.CHECKBOX_SIZE + 2 * touchMargin, + width: CheckboxBubble.CHECKBOX_SIZE + 2 * touchMargin, + }, + checkboxGroup + ); + this.setChecked(this.isChecked(this.sourceBlock.id)); + + this.svgRoot.prepend(checkboxGroup); + + this.clickListener = Blockly.browserEvents.bind( + this.svgRoot, + "mousedown", + null, + (event) => { + this.setChecked(!this.checked); + event.stopPropagation(); + event.preventDefault(); + } + ); + this.updateLocation(); + } + + setChecked(checked) { + this.checked = checked; + if (this.checked) { + Blockly.utils.dom.addClass(this.svgRoot, "checked"); + } else { + Blockly.utils.dom.removeClass(this.svgRoot, "checked"); + } + + Blockly.Events.fire( + new Blockly.Events.BlockChange( + this.sourceBlock, + "checkbox", + null, + !this.checked, + this.checked + ) + ); + } + + // Patched by scratch-gui to query the VM state. + isChecked(blockId) { + return false; + } + + isMovable() { + return false; + } + + getSvgRoot() { + return this.svgRoot; + } + + updateLocation() { + const blockLocation = this.sourceBlock.getRelativeToSurfaceXY(); + const blockBounds = this.sourceBlock.getHeightWidth(); + const x = this.sourceBlock.workspace.RTL + ? blockLocation.x + blockBounds.width + CheckboxBubble.CHECKBOX_MARGIN + : blockLocation.x - + CheckboxBubble.CHECKBOX_MARGIN - + CheckboxBubble.CHECKBOX_SIZE; + const y = + blockLocation.y + (blockBounds.height - CheckboxBubble.CHECKBOX_SIZE) / 2; + this.moveTo(x, y); + } + + moveTo(x, y) { + this.location.x = x; + this.location.y = y; + this.svgRoot.setAttribute("transform", `translate(${x}, ${y})`); + } + + getRelativeToSurfaceXY() { + return this.location; + } + + dispose() { + Blockly.utils.dom.removeNode(this.svgRoot); + Blockly.browserEvents.unbind(this.clickListener); + } + + // These methods are required by the interfaces, but intentionally have no + // implementation, largely because this bubble's location is fixed relative + // to its block and is not draggable by the user. + showContextMenu() {} + + setDragging(dragging) {} + + startDrag(event) {} + + drag(newLocation, event) {} + + moveDuringDrag(newLocation) {} + + endDrag() {} + + revertDrag() {} + + setDeleteStyle(enable) {} +} diff --git a/src/checkbox_icon.js b/src/checkbox_icon.js new file mode 100644 index 0000000000..65d902228f --- /dev/null +++ b/src/checkbox_icon.js @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { CheckboxBubble } from "./checkbox_bubble.js"; + +/** + * Invisible icon that exists solely to host the corresponding checkbox bubble. + * @implements {IIcon} + * @implements {IHasBubble} + */ +export class CheckboxIcon { + sourceBlock; + checkboxBubble; + type = new Blockly.icons.IconType("checkbox"); + + constructor(sourceBlock) { + this.sourceBlock = sourceBlock; + if (this.sourceBlock.workspace.isFlyout) { + this.checkboxBubble = new CheckboxBubble(this.sourceBlock); + } + } + + getType() { + return this.type; + } + + getWeight() { + return -1; + } + + getSize() { + // Awful hack to cancel out the default padding added to icons. + return new Blockly.utils.Size(-8, 0); + } + + isShownWhenCollapsed() { + return false; + } + + isClickableInFlyout() { + return false; + } + + bubbleIsVisible() { + return this.sourceBlock.workspace.isFlyout; + } + + onLocationChange(blockOrigin) { + this.checkboxBubble?.updateLocation(); + } + + setChecked(checked) { + this.checkboxBubble?.setChecked(checked); + } + + dispose() { + this.checkboxBubble?.dispose(); + } + + // These methods are required by the interfaces, but intentionally have no + // implementation, largely because this icon has no visual representation. + applyColour() {} + + hideForInsertionMarker() {} + + updateEditable() {} + + updateCollapsed() {} + + setOffsetInBlock() {} + + onClick() {} + + async setBubbleVisible(visible) {} + + initView(pointerDownListener) {} +} + +Blockly.registry.register( + Blockly.registry.Type.ICON, + "checkbox", + CheckboxIcon, + true +); diff --git a/src/css.js b/src/css.js index 4833a7e353..15a1185954 100644 --- a/src/css.js +++ b/src/css.js @@ -1018,7 +1018,7 @@ const styles = ` stroke: #c8c8c8; } - .checked > .blocklyFlyoutCheckbox { + .checked .blocklyFlyoutCheckbox { fill: var(--colour-toolboxHover); stroke: rgba(0,0,0,0.2); } diff --git a/src/flyout_extension_category_header.js b/src/flyout_extension_category_header.js new file mode 100644 index 0000000000..509abbbb64 --- /dev/null +++ b/src/flyout_extension_category_header.js @@ -0,0 +1,154 @@ +/** + * @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. + * @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. + * @extends {Blockly.FlyoutButton} + * @constructor + */ +export class FlyoutExtensionCategoryHeader extends Blockly.FlyoutButton { + extensionId; + imageElement; + mouseUpwrapper; + static statusButtonCallback; + + constructor(workspace, targetWorkspace, json, isFlyoutLabel) { + super(workspace, targetWorkspace, json, isFlyoutLabel); + /** + * @type {string} + */ + this.extensionId = json["id"]; + + 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, + () => { + FlyoutExtensionCategoryHeader.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; + } + + dispose() { + Blockly.browserEvents.unbind(this.mouseUpWrapper); + super.dispose(); + } +} + +export const StatusButtonState = { + READY: "ready", + NOT_READY: "not ready", +}; diff --git a/src/index.js b/src/index.js index e3ddc88a6b..f64594fd9f 100644 --- a/src/index.js +++ b/src/index.js @@ -33,12 +33,12 @@ 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"; import "./scratch_variable_model.js"; import "./scratch_connection_checker.js"; +import "./checkbox_icon.js"; import "./events/events_block_comment_change.js"; import "./events/events_block_comment_collapse.js"; import "./events/events_block_comment_create.js"; @@ -60,6 +60,9 @@ 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"; +import { registerStatusLabelFlyoutInflater } from "./status_label_flyout_inflater.js"; +import { registerScratchContinuousCategory } from "./scratch_continuous_category.js"; export * from "blockly/core"; export * from "./block_reporting.js"; @@ -73,6 +76,11 @@ export { CheckableContinuousFlyout }; export { ScratchVariables }; export { contextMenuItems }; export { FieldColourSlider, FieldNote }; +export { CheckboxBubble } from "./checkbox_bubble.js"; +export { + FlyoutExtensionCategoryHeader, + StatusButtonState, +} from "./flyout_extension_category_header.js"; export function inject(container, options) { registerFieldAngle(); @@ -85,6 +93,9 @@ export function inject(container, options) { registerFieldVariableGetter(); registerFieldVariable(); registerFieldVerticalSeparator(); + registerRecyclableBlockFlyoutInflater(); + registerStatusLabelFlyoutInflater(); + registerScratchContinuousCategory(); 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..3279307bb7 --- /dev/null +++ b/src/recyclable_block_flyout_inflater.js @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { CheckboxBubble } from "./checkbox_bubble.js"; + +export class RecyclableBlockFlyoutInflater extends Blockly.BlockFlyoutInflater { + recyclingEnabled = true; + recycledBlocks = new Map(); + + load(state, flyoutWorkspace) { + const block = super.load(state, flyoutWorkspace); + if (block.checkboxInFlyout) { + block.moveBy( + CheckboxBubble.CHECKBOX_SIZE + CheckboxBubble.CHECKBOX_MARGIN, + 0 + ); + } + + return block; + } + + setRecyclingEnabled(enabled) { + this.recyclingEnabled = enabled; + } + + createBlock(blockDefinition) { + const blockType = this.getTypeFromDefinition(blockDefinition); + return ( + this.getRecycledBlock(blockType) ?? + super.createBlock(blockDefinition, this.flyoutWorkspace) + ); + } + + 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; + } + + disposeElement(element) { + if (this.blockIsRecyclable(element)) { + this.removeListeners(element.id); + this.recycleBlock(element); + } else { + super.disposeElement(element); + } + } + + emptyRecycledBlocks() { + this.recycledBlocks + .values() + .forEach((block) => block.dispose(false, false)); + this.recycledBlocks.clear(); + } +} + +export function registerRecyclableBlockFlyoutInflater() { + Blockly.registry.unregister(Blockly.registry.Type.FLYOUT_INFLATER, "block"); + Blockly.registry.register( + Blockly.registry.Type.FLYOUT_INFLATER, + "block", + RecyclableBlockFlyoutInflater + ); +} diff --git a/src/scratch_continuous_category.js b/src/scratch_continuous_category.js index 0cbfa12e66..f5b83916ff 100644 --- a/src/scratch_continuous_category.js +++ b/src/scratch_continuous_category.js @@ -1,7 +1,20 @@ +/** + * @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 { + showStatusButton = false; + + constructor(toolboxItemDef, parentToolbox, opt_parent) { + super(toolboxItemDef, parentToolbox, opt_parent); + this.showStatusButton = toolboxItemDef["showStatusButton"] === "true"; + } + createIconDom_() { if (this.toolboxItemDef_.iconURI) { const icon = document.createElement("img"); @@ -20,11 +33,20 @@ class ScratchContinuousCategory extends ContinuousCategory { // Prevent hardcoding the background color to grey. this.rowDiv_.style.backgroundColor = ""; } + + shouldShowStatusButton() { + return this.showStatusButton; + } } -Blockly.registry.register( - Blockly.registry.Type.TOOLBOX_ITEM, - Blockly.ToolboxCategory.registrationName, - ScratchContinuousCategory, - true -); +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..adf8bb484b 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,41 @@ export class ScratchContinuousToolbox extends ContinuousToolbox { // Intentionally a no-op, Scratch manually manages refreshing the toolbox via forceRerender(). } + getInitialFlyoutContents_() { + /** @type {!Blockly.utils.toolbox.FlyoutItemInfoArray} */ + let contents = []; + for (const toolboxItem of this.getToolboxItems()) { + if (toolboxItem instanceof ScratchContinuousCategory) { + if (toolboxItem.shouldShowStatusButton()) { + contents.push({ + kind: "STATUS_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; + } + forceRerender() { const selectedCategoryName = this.selectedItem_?.getName(); super.refreshSelection(); diff --git a/src/status_label_flyout_inflater.js b/src/status_label_flyout_inflater.js new file mode 100644 index 0000000000..1a8c0c1b25 --- /dev/null +++ b/src/status_label_flyout_inflater.js @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from "blockly/core"; +import { FlyoutExtensionCategoryHeader } from "./flyout_extension_category_header.js"; + +class StatusLabelFlyoutInflater extends Blockly.LabelFlyoutInflater { + load(state, flyoutWorkspace) { + const label = new FlyoutExtensionCategoryHeader( + flyoutWorkspace, + flyoutWorkspace.targetWorkspace, + state, + true + ); + label.show(); + return label; + } +} + +export function registerStatusLabelFlyoutInflater() { + Blockly.registry.register( + Blockly.registry.Type.FLYOUT_INFLATER, + "status_label", + StatusLabelFlyoutInflater + ); +}