/** * @license * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * A toolbox category that provides a search field and displays matching blocks * in its flyout. */ import * as Blockly from 'blockly/core'; import {BlockSearcher} from './block_searcher'; /* eslint-disable @typescript-eslint/naming-convention */ /** * A toolbox category that provides a search field and displays matching blocks * in its flyout. */ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { private static readonly START_SEARCH_SHORTCUT = 'startSearch'; static readonly SEARCH_CATEGORY_KIND = 'search'; private readonly SEARCH_INPUT_ID = 'toolbox-search-input'; private searchField?: HTMLInputElement; private blockSearcher = new BlockSearcher(); /** * Initializes a ToolboxSearchCategory. * * @param categoryDef The information needed to create a category in the * toolbox. * @param parentToolbox The parent toolbox for the category. * @param opt_parent The parent category or null if the category does not have * a parent. */ constructor( categoryDef: Blockly.utils.toolbox.CategoryInfo, parentToolbox: Blockly.IToolbox, opt_parent?: Blockly.ICollapsibleToolboxItem, ) { super(categoryDef, parentToolbox, opt_parent); this.initBlockSearcher(); this.registerShortcut(); } /** * Initializes the search field toolbox category. * * @returns The
that will be displayed in the toolbox. */ protected override createDom_(): HTMLDivElement { const dom = super.createDom_(); this.searchField = document.createElement('input'); this.searchField.id = this.SEARCH_INPUT_ID; this.searchField.type = 'search'; this.searchField.placeholder = 'Search for blocks'; this.workspace_.RTL ? (this.searchField.style.marginRight = '8px') : (this.searchField.style.marginLeft = '8px'); this.searchField.addEventListener('keydown', (event) => { if (event.key === 'ArrowUp' && this.searchField?.selectionStart === 0) { const previous = this.parentToolbox_.getNavigator().getPreviousNode(); if (previous) { Blockly.getFocusManager().focusNode(previous); } return; } else if ( event.key === 'ArrowRight' && this.searchField?.selectionStart === this.searchField?.value.length ) { const previous = this.parentToolbox_.getNavigator().getInNode(); if (previous) { Blockly.getFocusManager().focusNode(previous); } return; } else if ( event.key === 'ArrowDown' && this.searchField?.selectionStart === this.searchField?.value.length ) { const next = this.parentToolbox_.getNavigator().getNextNode(); if (next) { Blockly.getFocusManager().focusNode(next); } return; } else if (event.key === 'Escape' && this.searchField) { if (this.searchField.value !== '') { this.searchField.value = ''; event.stopPropagation(); } } this.matchBlocks(); }); this.rowContents_?.replaceChildren(this.searchField); return dom; } /** The ID of the toolbox item must match the ID of the focusable node. */ override getId(): string { return this.SEARCH_INPUT_ID; } /** * Registers a shortcut for displaying the toolbox search category. */ private registerShortcut() { const shortcut = Blockly.ShortcutRegistry.registry.createSerializedKey( Blockly.utils.KeyCodes.B, [Blockly.utils.KeyCodes.CTRL], ); Blockly.ShortcutRegistry.registry.register({ name: ToolboxSearchCategory.START_SEARCH_SHORTCUT, callback: () => { Blockly.getFocusManager().focusNode(this); return true; }, keyCodes: [shortcut], }); } /** * Returns a list of block types that are present in the toolbox definition. * * @param schema A toolbox item definition. * @param allBlocks The set of all available blocks that have been encountered * so far. */ private getAvailableBlocks( schema: Blockly.utils.toolbox.ToolboxItemInfo, allBlocks: Set, ) { if ('contents' in schema) { schema.contents.forEach((contents) => { this.getAvailableBlocks(contents, allBlocks); }); } else if (schema.kind.toLowerCase() === 'block') { if ('type' in schema && schema.type) { allBlocks.add(schema); } } } /** * Builds the BlockSearcher index based on the available blocks. */ private initBlockSearcher() { const availableBlocks = new Set(); this.workspace_.options.languageTree?.contents?.forEach((item) => this.getAvailableBlocks(item, availableBlocks), ); this.blockSearcher.indexBlocks([...availableBlocks]); } /** See IFocusableNode.getFocusableElement. */ override getFocusableElement(): HTMLElement | SVGElement { if (!this.searchField) { throw Error('This field currently has no representative DOM element.'); } return this.searchField; } /** See IFocusableNode.onNodeFocus. */ override onNodeFocus(): void { super.onNodeFocus(); if (!this.searchField?.value) { Blockly.renderManagement.finishQueuedRenders().then(() => { this.matchBlocks(); }); } } /** * Filters the available blocks based on the current query string. */ private matchBlocks() { const query = this.searchField?.value || ''; const oldCount = this.flyoutItems_.length; this.flyoutItems_ = query ? this.blockSearcher.blockTypesMatching(query) : []; const newCount = this.flyoutItems_.length; if (oldCount !== newCount) { Blockly.utils.aria.announceDynamicAriaState( `${newCount} matching blocks`, ); } if (!this.flyoutItems_.length) { this.flyoutItems_.push({ kind: 'label', text: query.length < 3 ? 'Type to search for blocks' : 'No matching blocks found', }); } this.parentToolbox_.refreshSelection(); } /** * Disposes of this category. */ override dispose() { super.dispose(); Blockly.ShortcutRegistry.registry.unregister( ToolboxSearchCategory.START_SEARCH_SHORTCUT, ); } } // Make the clear button clickable in Safari. Blockly.Css.register(` input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: searchfield-cancel-button; pointer-events: auto !important; position: relative; z-index: 10; }`); Blockly.registry.register( Blockly.registry.Type.TOOLBOX_ITEM, ToolboxSearchCategory.SEARCH_CATEGORY_KIND, ToolboxSearchCategory, );