UNPKG

@blockly/continuous-toolbox

Version:

A Blockly plugin that adds a continous-scrolling style toolbox and flyout

290 lines (260 loc) 8.46 kB
/** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Flyout that supports always-open continuous scrolling. */ import * as Blockly from 'blockly/core'; import {ContinuousToolbox} from './ContinuousToolbox'; import {ContinuousFlyoutMetrics} from './ContinuousMetricsFlyout'; /** * Class for continuous flyout. */ export class ContinuousFlyout extends Blockly.VerticalFlyout { /** @override */ constructor(workspaceOptions) { super(workspaceOptions); /** * List of scroll positions for each category. * @type {!Array<{name: string, position: !Object}>} */ this.scrollPositions = []; /** * Target scroll position, used to smoothly scroll to a given category * location when selected. * @type {?number} */ this.scrollTarget = null; /** * The percentage of the distance to the scrollTarget that should be * scrolled at a time. Lower values will produce a smoother, slower scroll. * @type {number} */ this.scrollAnimationFraction = 0.3; /** * Whether to recycle blocks when refreshing the flyout. When false, do not * allow anything to be recycled. The default is to recycle. * @type {boolean} * @private */ this.recyclingEnabled_ = true; this.workspace_.setMetricsManager( new ContinuousFlyoutMetrics(this.workspace_, this), ); this.workspace_.addChangeListener((e) => { if (e.type === Blockly.Events.VIEWPORT_CHANGE) { this.selectCategoryByScrollPosition_(-this.workspace_.scrollY); } }); this.autoClose = false; } /** * Gets parent toolbox. * Since we registered the ContinuousToolbox, we know that's its type. * @returns {!ContinuousToolbox} Toolbox that owns this flyout. * @private */ getParentToolbox_() { const toolbox = this.targetWorkspace.getToolbox(); return /** @type {!ContinuousToolbox} */ (toolbox); } /** * 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.buttons_.filter( (button) => button.isLabel() && this.getParentToolbox_().getCategoryByName(button.getButtonText()), ); for (const button of categoryLabels) { if (button.isLabel()) { this.scrollPositions.push({ name: button.getButtonText(), position: button.getPosition(), }); } } } /** * Returns the scroll position for the given category name. * @param {string} name Category name. * @returns {?Object} Scroll position for given category, or null if not * found. * @package */ getCategoryScrollPosition(name) { for (const scrollInfo of this.scrollPositions) { if (scrollInfo.name === name) { return scrollInfo.position; } } console.warn(`Scroll position not recorded for category ${name}`); return null; } /** * Selects an item in the toolbox based on the scroll position of the flyout. * @param {number} position Current scroll position of the workspace. * @private */ selectCategoryByScrollPosition_(position) { // If we are currently auto-scrolling, due to selecting a category by // clicking on it, do not update the category selection. if (this.scrollTarget) { return; } const scaledPosition = Math.round(position / this.workspace_.scale); // Traverse the array of scroll positions in reverse, so we can select the // furthest category that the scroll position is beyond. for (let i = this.scrollPositions.length - 1; i >= 0; i--) { const category = this.scrollPositions[i]; if (scaledPosition >= category.position.y) { this.getParentToolbox_().selectCategoryByName(category.name); return; } } } /** * Scrolls flyout to given position. * @param {number} position The x coordinate to scroll to. */ scrollTo(position) { // Set the scroll target to either the scaled position or the lowest // possible scroll point, whichever is smaller. const metrics = this.workspace_.getMetrics(); this.scrollTarget = Math.min( position * this.workspace_.scale, metrics.scrollHeight - metrics.viewHeight, ); this.stepScrollAnimation_(); } /** * Step the scrolling animation by scrolling a fraction of the way to * a scroll target, and request the next frame if necessary. * @private */ stepScrollAnimation_() { if (!this.scrollTarget) { return; } const currentScrollPos = -this.workspace_.scrollY; const diff = this.scrollTarget - currentScrollPos; if (Math.abs(diff) < 1) { this.workspace_.scrollbar.setY(this.scrollTarget); this.scrollTarget = null; return; } this.workspace_.scrollbar.setY( currentScrollPos + diff * this.scrollAnimationFraction, ); requestAnimationFrame(this.stepScrollAnimation_.bind(this)); } /** * Add additional padding to the bottom of the flyout if needed, * in order to make it possible to scroll to the top of the last category. * @param {!Blockly.MetricsManager.ContainerRegion} contentMetrics Content * metrics for the flyout. * @param {!Blockly.MetricsManager.ContainerRegion} viewMetrics View metrics * for the flyout. * @returns {number} Additional bottom padding. */ calculateBottomPadding(contentMetrics, viewMetrics) { if (this.scrollPositions.length > 0) { const lastCategory = this.scrollPositions[this.scrollPositions.length - 1]; const lastPosition = lastCategory.position.y * this.workspace_.scale; const lastCategoryHeight = contentMetrics.height - lastPosition; if (lastCategoryHeight < viewMetrics.height) { return viewMetrics.height - lastCategoryHeight; } } return 0; } /** @override */ getX() { if ( this.isVisible() && this.targetWorkspace.toolboxPosition === this.toolboxPosition_ && this.targetWorkspace.getToolbox() && this.toolboxPosition_ !== Blockly.utils.toolbox.Position.LEFT ) { // This makes it so blocks cannot go under the flyout in RTL mode. return this.targetWorkspace.getMetricsManager().getViewMetrics().width; } return super.getX(); } /** * @override */ show(flyoutDef) { super.show(flyoutDef); this.recordScrollPositions(); this.workspace_.resizeContents(); } /** * Determine if this block can be recycled in the flyout. Blocks that have no * variables and are not dynamic shadows can be recycled. * @param {!Blockly.BlockSvg} block The block to attempt to recycle. * @returns {boolean} True if the block can be recycled. * @protected */ 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 instanceof Blockly.FieldVariable) { 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; } /** * Sets the function used to determine whether a block is recyclable. * @param {function(!Blockly.BlockSvg):boolean} func The function used to * determine if a block is recyclable. * @public */ setBlockIsRecyclable(func) { this.blockIsRecyclable_ = func; } /** * Set whether the flyout can recycle blocks. * @param {boolean} isEnabled True to allow blocks to be recycled, false * otherwise. * @public */ setRecyclingEnabled(isEnabled) { this.recyclingEnabled_ = isEnabled; } }