UNPKG

@blockly/continuous-toolbox

Version:

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

312 lines (277 loc) 9.72 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 './ContinuousFlyoutMetrics'; import {RecyclableBlockFlyoutInflater} from './RecyclableBlockFlyoutInflater'; export interface LabelFlyoutItem extends Blockly.FlyoutItem { // Blockly.FlyoutButton represents both buttons and labels; a label is just // a borderless, non-clickable button. getElement(): Blockly.FlyoutButton; } /** * Class for continuous flyout. */ export class ContinuousFlyout extends Blockly.VerticalFlyout { /** * Target scroll position, used to smoothly scroll to a given category * location when selected. */ private scrollTarget?: number; /** * Map from category name to its position in the flyout. */ private scrollPositions = new Map<string, number>(); /** * The percentage of the distance to the scrollTarget that should be * scrolled at a time. Lower values will produce a smoother, slower scroll. */ protected scrollAnimationFraction = 0.3; /** * Prevents the flyout from closing automatically when a block is dragged out. */ override autoClose = false; /** * Creates a new ContinuousFlyout. * * @param workspaceOptions The injection options for the flyout's workspace. */ constructor(workspaceOptions: Blockly.Options) { super(workspaceOptions); this.getWorkspace().setMetricsManager( new ContinuousFlyoutMetrics(this.getWorkspace(), this), ); this.getWorkspace().addChangeListener((e: Blockly.Events.Abstract) => { if (e.type === Blockly.Events.VIEWPORT_CHANGE) { this.selectCategoryByScrollPosition(-this.getWorkspace().scrollY); } }); this.setRecyclingEnabled(true); } /** * Gets parent toolbox. * Since we registered the ContinuousToolbox, we know that's its type. * * @returns Toolbox that owns this flyout. */ private getParentToolbox(): ContinuousToolbox { return this.targetWorkspace.getToolbox() as ContinuousToolbox; } /** * 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. */ private recordScrollPositions() { this.scrollPositions.clear(); this.getContents() .filter(this.toolboxItemIsLabel.bind(this)) .map((item) => item.getElement()) .forEach((label) => { this.scrollPositions.set( label.getButtonText(), Math.max(0, label.getPosition().y - this.GAP_Y / 2), ); }); } /** * Validates and typechecks that the given toolbox item represents a label. * * @param item The toolbox item to check. * @returns True if the item represents a label in the flyout, and is a * Blockly.FlyoutButton. */ protected toolboxItemIsLabel( item: Blockly.FlyoutItem, ): item is LabelFlyoutItem { const element = item.getElement(); return !!( item.getType() === 'label' && // Note that `FlyoutButton` represents both buttons and labels. element instanceof Blockly.FlyoutButton && element.isLabel() && this.getParentToolbox().getCategoryByName(element.getButtonText()) ); } /** * Returns the scroll position for the given category name. * * @param name Category name. * @returns Scroll position for given category in workspace units, or null if * not found. */ getCategoryScrollPosition(name: string): number | null { const position = this.scrollPositions.get(name); if (position === undefined) { console.warn(`Scroll position not recorded for category ${name}`); } return position ?? null; } /** * Selects an item in the toolbox based on the scroll position of the flyout. * * @param position Current scroll position of the workspace. */ private selectCategoryByScrollPosition(position: number) { // 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.getWorkspace().scale); // Traverse the array of scroll positions in reverse, so we can select the // furthest category that the scroll position is beyond. for (const [name, position] of [ ...this.scrollPositions.entries(), ].reverse()) { if (scaledPosition >= position) { this.getParentToolbox().selectCategoryByName(name); return; } } } /** * Scrolls the flyout to given position. * * @param position The Y coordinate to scroll to. */ scrollTo(position: number) { // Set the scroll target to either the scaled position or the lowest // possible scroll point, whichever is smaller. const metrics = this.getWorkspace().getMetrics(); this.scrollTarget = Math.min( position * this.getWorkspace().scale, metrics.scrollHeight - metrics.viewHeight, ); this.stepScrollAnimation(); } /** * Scrolls the flyout to display the given category at the top. * * @param category The toolbox category to scroll to in the flyout. */ scrollToCategory(category: Blockly.ISelectableToolboxItem) { const position = this.scrollPositions.get(category.getName()); if (position === undefined) { console.warn(`Scroll position not recorded for category ${name}`); return; } this.scrollTo(position); } /** * 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 === undefined) return; const currentScrollPos = -this.getWorkspace().scrollY; const diff = this.scrollTarget - currentScrollPos; if (Math.abs(diff) < 1) { this.getWorkspace().scrollbar?.setY(this.scrollTarget); this.scrollTarget = undefined; return; } this.getWorkspace().scrollbar?.setY( currentScrollPos + diff * this.scrollAnimationFraction, ); requestAnimationFrame(this.stepScrollAnimation.bind(this)); } /** * Handles mouse wheel events. * * @param e The mouse wheel event to handle. */ // eslint-disable-next-line @typescript-eslint/naming-convention protected override wheel_(e: WheelEvent) { // Don't scroll in response to mouse wheel events if we're currently // animating scrolling to a category. if (this.scrollTarget) return; super.wheel_(e); } /** * Calculates the additional padding needed at the bottom of the flyout in * order to make it possible to scroll to the top of the last category. * * @param contentMetrics Content metrics for the flyout. * @param viewMetrics View metrics for the flyout. * @returns The additional bottom padding needed. */ calculateBottomPadding( contentMetrics: Blockly.MetricsManager.ContainerRegion, viewMetrics: Blockly.MetricsManager.ContainerRegion, ): number { if (this.scrollPositions.size === 0) return 0; const lastPosition = ([...this.scrollPositions.values()].pop() ?? 0) * this.getWorkspace().scale; const lastCategoryHeight = contentMetrics.height - lastPosition; if (lastCategoryHeight < viewMetrics.height) { return viewMetrics.height - lastCategoryHeight; } return 0; } /** * Returns the X coordinate for the flyout's position. */ override getX(): number { if ( this.isVisible() && // Make sure that this flyout is associated with a toolbox and not e.g. // a simple flyout or the trashcan flyout. 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(); } /** * Displays the given contents in the flyout. * * @param flyoutDef A string or JSON object specifying the contents of the * flyout. */ override show(flyoutDef: Blockly.utils.toolbox.FlyoutDefinition | string) { super.show(flyoutDef); this.recordScrollPositions(); this.getWorkspace().resizeContents(); if (!this.getParentToolbox().getSelectedItem()) { this.selectCategoryByScrollPosition(0); } this.getRecyclableInflater().emptyRecycledBlocks(); } /** * Sets the function used to determine whether a block is recyclable. * * @param func The function used to determine if a block is recyclable. */ setBlockIsRecyclable(func: (block: Blockly.Block) => boolean) { this.getRecyclableInflater().recycleEligibilityChecker = func; } /** * Set whether the flyout can recycle blocks. * * @param isEnabled True to allow blocks to be recycled, false otherwise. */ setRecyclingEnabled(isEnabled: boolean) { this.getRecyclableInflater().recyclingEnabled = isEnabled; } /** * Returns the recyclable block flyout inflater. * * @returns The recyclable inflater. */ protected getRecyclableInflater(): RecyclableBlockFlyoutInflater { const inflater = this.getInflaterForType('block'); if (!(inflater instanceof RecyclableBlockFlyoutInflater)) { throw new Error('The RecyclableBlockFlyoutInflater is not registered.'); } return inflater; } }