diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 1fe29609ba6..e778a82b5a8 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -671,5 +671,10 @@ "WORKSPACE_SEARCH_FIND_PREVIOUS": "Find previous", "WORKSPACE_SEARCH_CLOSE": "Close search bar", "WORKSPACE_SEARCH_NO_MATCHES": "No matching blocks", - "WORKSPACE_SEARCH_MATCH": "Match %1 of %2: %3" + "WORKSPACE_SEARCH_MATCH": "Match %1 of %2: %3", + "TOOLBOX_SEARCH_PLACEHOLDER": "Search for blocks", + "TOOLBOX_SEARCH_PROMPT": "Type to search for blocks", + "TOOLBOX_SEARCH_NO_RESULTS": "No matching blocks found", + "TOOLBOX_SEARCH_RESULT_COUNT_ONE": "1 matching block", + "TOOLBOX_SEARCH_RESULT_COUNT": "%1 matching blocks" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index 320dba23d35..7a9770ce39d 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -676,5 +676,10 @@ "WORKSPACE_SEARCH_FIND_PREVIOUS": "ARIA label for the workspace search button that selects the previous matching block.", "WORKSPACE_SEARCH_CLOSE": "ARIA label for the button that closes the workspace search bar.", "WORKSPACE_SEARCH_NO_MATCHES": "ARIA live region message announced when workspace search finds no matching blocks.", - "WORKSPACE_SEARCH_MATCH": "ARIA live region message announcing the currently highlighted workspace search match. \n\nParameters:\n* %1 - 1-based index of the current match\n* %2 - total number of matches\n* %3 - accessible label of the current block \n\nExamples:\n* 'Match 1 of 3: print, hello'" + "WORKSPACE_SEARCH_MATCH": "ARIA live region message announcing the currently highlighted workspace search match. \n\nParameters:\n* %1 - 1-based index of the current match\n* %2 - total number of matches\n* %3 - accessible label of the current block \n\nExamples:\n* 'Match 1 of 3: print, hello'", + "TOOLBOX_SEARCH_PLACEHOLDER": "Placeholder text in the toolbox search category's input field.", + "TOOLBOX_SEARCH_PROMPT": "Message shown in the toolbox search flyout before a query has been entered.", + "TOOLBOX_SEARCH_NO_RESULTS": "Message shown in the toolbox search flyout when a query matches no blocks.", + "TOOLBOX_SEARCH_RESULT_COUNT_ONE": "Screen reader announcement when a toolbox search matches exactly one block.", + "TOOLBOX_SEARCH_RESULT_COUNT": "Screen reader announcement of how many blocks a toolbox search matched. \n\nParameters:\n* %1 - a number of blocks, which may be zero or greater than one." } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index 068eda42a32..2529f5def0c 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -2533,4 +2533,20 @@ Blockly.Msg.WORKSPACE_SEARCH_NO_MATCHES = 'No matching blocks'; /// ARIA live region message announcing the currently highlighted workspace search match. /// \n\nParameters:\n* %1 - 1-based index of the current match\n* %2 - total number of matches\n* %3 - accessible label of the current block /// \n\nExamples:\n* "Match 1 of 3: print, hello" -Blockly.Msg.WORKSPACE_SEARCH_MATCH = 'Match %1 of %2: %3'; \ No newline at end of file +Blockly.Msg.WORKSPACE_SEARCH_MATCH = 'Match %1 of %2: %3'; +/** @type {string} */ +/// Placeholder text in the toolbox search category's input field. +Blockly.Msg.TOOLBOX_SEARCH_PLACEHOLDER = 'Search for blocks'; +/** @type {string} */ +/// Message shown in the toolbox search flyout before a query has been entered. +Blockly.Msg.TOOLBOX_SEARCH_PROMPT = 'Type to search for blocks'; +/** @type {string} */ +/// Message shown in the toolbox search flyout when a query matches no blocks. +Blockly.Msg.TOOLBOX_SEARCH_NO_RESULTS = 'No matching blocks found'; +/** @type {string} */ +/// Screen reader announcement when a toolbox search matches exactly one block. +Blockly.Msg.TOOLBOX_SEARCH_RESULT_COUNT_ONE = '1 matching block'; +/** @type {string} */ +/// Screen reader announcement of how many blocks a toolbox search matched. +/// \n\nParameters:\n* %1 - a number of blocks, which may be zero or greater than one. +Blockly.Msg.TOOLBOX_SEARCH_RESULT_COUNT = '%1 matching blocks'; \ No newline at end of file diff --git a/packages/plugins/toolbox-search/package.json b/packages/plugins/toolbox-search/package.json index a24e83537ba..b8cb9a3b95c 100644 --- a/packages/plugins/toolbox-search/package.json +++ b/packages/plugins/toolbox-search/package.json @@ -43,6 +43,9 @@ "@blockly/dev-scripts": "^13.3.0", "@blockly/dev-tools": "^13.3.0", "chai": "^6.2.2", + "jsdom": "^30.0.1", + "jsdom-global": "^3.0.2", + "sinon": "^22.1.0", "typescript": "^6.0.3" }, "peerDependencies": { diff --git a/packages/plugins/toolbox-search/src/block_searcher.ts b/packages/plugins/toolbox-search/src/block_searcher.ts index 005974b730b..e7448c2ef91 100644 --- a/packages/plugins/toolbox-search/src/block_searcher.ts +++ b/packages/plugins/toolbox-search/src/block_searcher.ts @@ -6,6 +6,24 @@ import * as Blockly from 'blockly/core'; +/** A value a field could be rewritten to, in order to make a query visible. */ +interface FieldCandidate { + // The id, in the saved state, of the block owning the field. + blockId: string; + fieldName: string; + // Lowercase text that would be visible if this value were set. + label: string; + // The value to write into the block's serialized fields, either an option value + // for dropdown options, or an object with name and type for variable fields. + value: string | {name: string; type: string}; + // Whether the field already holds this value. + selected: boolean; +} + +/** A block in a toolbox definition, or in a serialized block state. */ +type BlockNode = + Blockly.utils.toolbox.BlockInfo | Blockly.serialization.blocks.State; + /** * A class that provides methods for indexing and searching blocks. */ @@ -15,6 +33,28 @@ export class BlockSearcher { Set >(); + // A map of blocks to the text that was indexed for them, used to filter + // the results of a search to only those blocks that contain the search term. + private blockText = new Map(); + // A map of blocks to the field values they could be rewritten to, used to + // show a block with the searched text visible on it. + private fieldCandidates = new Map< + Blockly.utils.toolbox.BlockInfo, + FieldCandidate[] + >(); + // The workspace whose variables searches are matched against. + private workspace: Blockly.Workspace; + // A map of blocks to the serialized state of the block built while indexing. + // Rewrites go through this rather than the toolbox definition, because it + // carries an id for every block in the tree and the definition does not. + private blockStates = new Map< + Blockly.utils.toolbox.BlockInfo, + Blockly.utils.toolbox.BlockInfo + >(); + + constructor(workspace: Blockly.Workspace) { + this.workspace = workspace; + } /** * Populates the cached map of trigrams to the blocks they correspond to. * @@ -26,40 +66,142 @@ export class BlockSearcher { * @param blockInfos A list of blocks to index. */ indexBlocks(blockInfos: Blockly.utils.toolbox.BlockInfo[]) { + this.blockText.clear(); + this.fieldCandidates.clear(); + this.trigramsToBlocks.clear(); + this.blockStates.clear(); + const workspaceVariables = this.workspace + .getVariableMap() + .getAllVariables() + .sort(Blockly.Variables.compareByName); + Blockly.Events.disable(); const blockCreationWorkspace = new Blockly.Workspace(); blockInfos.forEach((blockInfo) => { const type = blockInfo.type; if (!type || type === '') return; - const block = blockCreationWorkspace.newBlock(type); + blockCreationWorkspace.clear(); + workspaceVariables.forEach((variable) => + blockCreationWorkspace + .getVariableMap() + .createVariable(variable.getName(), variable.getType()), + ); + const block = Blockly.serialization.blocks.append( + blockInfo as Blockly.serialization.blocks.State, + blockCreationWorkspace, + ); + const state = Blockly.serialization.blocks.save(block); + if (state) + // Merged with the original block info to preserve the kind and other props. + this.blockStates.set(blockInfo, {...blockInfo, ...state}); this.indexBlockText(type.replaceAll('_', ' '), blockInfo); - block.inputList.forEach((input) => { - input.fieldRow.forEach((field) => { - this.indexDropdownOption(field, blockInfo); - this.indexBlockText(field.getText(), blockInfo); + // Index the text of every field on the block and its descendants, and + // record the values each field could be rewritten to. + block.getDescendants(false).forEach((descendantBlock) => { + descendantBlock.inputList.forEach((input) => { + input.fieldRow.forEach((field) => { + this.indexBlockText(field.getText(), blockInfo); + if (field instanceof Blockly.FieldVariable) { + this.indexVariableCandidates( + field, + descendantBlock.id, + blockInfo, + workspaceVariables, + ); + } else { + this.indexDropdownCandidates( + field, + descendantBlock.id, + blockInfo, + ); + } + }); }); }); }); + blockCreationWorkspace.dispose(); + Blockly.Events.enable(); } /** - * Check if the field is a dropdown, and index every text in the option + * Indexes a dropdown field's option labels and records each option + * as a value the field could be rewritten to. * - * @param field We need to check the type of field + * @param field The field to index. + * @param blockId The id of the block containing the field. * @param block The block to associate the trigrams with. */ - private indexDropdownOption( + private indexDropdownCandidates( field: Blockly.Field, + blockId: string, block: Blockly.utils.toolbox.BlockInfo, ) { - if (field instanceof Blockly.FieldDropdown) { - field.getOptions(true).forEach((option) => { - if (typeof option[0] === 'string') { - this.indexBlockText(option[0], block); - } else if ('alt' in option[0]) { - this.indexBlockText(option[0].alt, block); - } - }); + if (!(field instanceof Blockly.FieldDropdown)) { + return; } + field.getOptions(true).forEach(([label, value]) => { + const text = + typeof label === 'string' + ? label + : label && 'alt' in label + ? label.alt + : ''; + if (!text) return; + this.indexBlockText(text, block); + if (!field.name || typeof value !== 'string') return; + this.addCandidate(block, { + blockId, + fieldName: field.name, + label: text.toLowerCase(), + value, + selected: value === field.getValue(), + }); + }); + } + + /** + * Indexes every workspace variable name against the block and records each + * as a value the field could be rewritten to. + * + * A field set to a variable that exists only in the flyout is left alone. + * + * @param field The variable field to index. + * @param blockId The id of the block containing the field. + * @param block The block to associate the trigrams with. + * @param variables The workspace's variables, sorted by name. + */ + private indexVariableCandidates( + field: Blockly.FieldVariable, + blockId: string, + block: Blockly.utils.toolbox.BlockInfo, + variables: Array>, + ) { + const current = field.getText(); + if (!field.name || !variables.some((v) => v.getName() === current)) return; + variables.forEach((variable) => { + this.indexBlockText(variable.getName(), block); + this.addCandidate(block, { + blockId, + fieldName: field.name as string, + label: variable.getName().toLowerCase(), + value: {name: variable.getName(), type: variable.getType()}, + selected: variable.getName() === current, + }); + }); + } + + /** + * Records a value a field could be rewritten to. + * + * @param block The block the field belongs to. + * @param candidate The value and the text it would make visible. + */ + private addCandidate( + block: Blockly.utils.toolbox.BlockInfo, + candidate: FieldCandidate, + ) { + const candidates = this.fieldCandidates.get(block) ?? []; + candidates.push(candidate); + this.fieldCandidates.set(block, candidates); } /** @@ -69,7 +211,7 @@ export class BlockSearcher { * @returns A list of blocks matching the query. */ blockTypesMatching(query: string): Blockly.utils.toolbox.BlockInfo[] { - return [ + const candidates = [ ...this.generateTrigrams(query) .map((trigram) => { return ( @@ -82,6 +224,40 @@ export class BlockSearcher { }) .values(), ]; + + const searchTerm = query.toLowerCase(); + const matches = candidates.filter((block) => + this.blockText.get(block)?.some((text) => text.includes(searchTerm)), + ); + // The flyout creates one getter per variable, and they all collapse onto + // the same block once bound, so results are keyed by content. + const results = new Map(); + for (const match of matches) { + const candidates = (this.fieldCandidates.get(match) ?? []).filter( + (candidate) => candidate.label.includes(searchTerm), + ); + // If the value is already showing, the block doesn't need to be updated. + const hasUnselectedCandidates = + candidates.length > 0 && + !candidates.some((candidate) => candidate.selected); + const variants = hasUnselectedCandidates + ? candidates.map((candidate) => + this.updateBlockField( + this.blockStates.get(match) ?? match, + candidate, + ), + ) + : [match]; + for (const variant of variants) { + // Ignore the id when comparing, so that we don't end up with two + // copies of the same block. + const key = JSON.stringify(variant, (property, value) => + property === 'id' ? undefined : value, + ); + results.set(key, hasUnselectedCandidates ? JSON.parse(key) : variant); + } + } + return [...results.values()]; } /** @@ -92,6 +268,9 @@ export class BlockSearcher { * @param block The block to associate the trigrams with. */ private indexBlockText(text: string, block: Blockly.utils.toolbox.BlockInfo) { + const texts = this.blockText.get(block) ?? []; + texts.push(text.toLowerCase()); + this.blockText.set(block, texts); this.generateTrigrams(text).forEach((trigram) => { const blockSet = this.trigramsToBlocks.get(trigram) ?? @@ -116,7 +295,6 @@ export class BlockSearcher { for (let start = 0; start <= normalizedInput.length - 3; start++) { trigrams.push(normalizedInput.substring(start, start + 3)); } - return trigrams; } @@ -133,4 +311,43 @@ export class BlockSearcher { ): Set { return new Set([...a].filter((value) => b.has(value))); } + + /** + * Returns the block with the given id, searched depth-first. + * + * @param node The block to search, along with its descendants. + * @param id The block id to find. + * @returns The matching block, or null if it isn't there. + */ + private getNodeById(node: BlockNode, id: string): BlockNode | null { + if (node.id === id) return node; + for (const connection of [...Object.values(node.inputs ?? {}), node.next]) { + for (const childBlock of [connection?.block, connection?.shadow]) { + const found = childBlock && this.getNodeById(childBlock, id); + if (found) return found; + } + } + return null; + } + + /** + * Returns a copy of a block's state with one of its fields set. + * + * @param info The state to copy. + * @param candidate The field to set, which may be on any block in the tree, + * and the value to set it to. + * @returns A copy with the field set, or info itself if the block is gone. + */ + private updateBlockField( + info: Blockly.utils.toolbox.BlockInfo, + candidate: FieldCandidate, + ): Blockly.utils.toolbox.BlockInfo { + const copy = JSON.parse( + JSON.stringify(info), + ) as Blockly.utils.toolbox.BlockInfo; + const target = this.getNodeById(copy, candidate.blockId); + if (!target) return info; + target.fields = {...target.fields, [candidate.fieldName]: candidate.value}; + return copy; + } } diff --git a/packages/plugins/toolbox-search/src/toolbox_search.ts b/packages/plugins/toolbox-search/src/toolbox_search.ts index 25e4ba43fe0..9b699041e57 100644 --- a/packages/plugins/toolbox-search/src/toolbox_search.ts +++ b/packages/plugins/toolbox-search/src/toolbox_search.ts @@ -22,7 +22,10 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { static readonly SEARCH_CATEGORY_KIND = 'search'; private readonly SEARCH_INPUT_ID = 'toolbox-search-input'; private searchField?: HTMLInputElement; - private blockSearcher = new BlockSearcher(); + private blockSearcher: BlockSearcher; + private onChangeWrapper?: (event: Blockly.Events.Abstract) => void; + private indexedBlocks = ''; + private boundEvents: Blockly.browserEvents.Data[] = []; /** * Initializes a ToolboxSearchCategory. @@ -39,8 +42,11 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { opt_parent?: Blockly.ICollapsibleToolboxItem, ) { super(categoryDef, parentToolbox, opt_parent); + this.blockSearcher = new BlockSearcher(this.workspace_); this.initBlockSearcher(); this.registerShortcut(); + this.onChangeWrapper = this.handleWorkspaceChange.bind(this); + this.workspace_.addChangeListener(this.onChangeWrapper); } /** @@ -53,44 +59,63 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { this.searchField = document.createElement('input'); this.searchField.id = this.SEARCH_INPUT_ID; this.searchField.type = 'search'; - this.searchField.placeholder = 'Search for blocks'; + this.searchField.placeholder = Blockly.Msg['TOOLBOX_SEARCH_PLACEHOLDER']; 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.boundEvents.push( + Blockly.browserEvents.conditionalBind( + this.searchField, + 'keydown', + this, + (event: KeyboardEvent) => { + 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(); + // Removes matches from the flyout after programmatically clearing the search field. + this.matchBlocks(); + } + } + }, + ), + // When the user types in the search field, update the flyout to show matching blocks. + Blockly.browserEvents.conditionalBind( + this.searchField, + 'input', + this, + () => this.matchBlocks(), + ), + ); this.rowContents_?.replaceChildren(this.searchField); return dom; } @@ -106,7 +131,7 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { private registerShortcut() { const shortcut = Blockly.ShortcutRegistry.registry.createSerializedKey( Blockly.utils.KeyCodes.B, - [Blockly.utils.KeyCodes.CTRL], + [Blockly.utils.KeyCodes.CTRL_CMD], ); Blockly.ShortcutRegistry.registry.register({ name: ToolboxSearchCategory.START_SEARCH_SHORTCUT, @@ -129,7 +154,20 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { schema: Blockly.utils.toolbox.ToolboxItemInfo, allBlocks: Set, ) { - if ('contents' in schema) { + if ('custom' in schema && schema.custom) { + const flyoutCallback = this.workspace_.getToolboxCategoryCallback( + schema.custom, + ); + if (!flyoutCallback) { + return; + } + const flyoutDef = flyoutCallback(this.workspace_); + Blockly.utils.toolbox + .convertFlyoutDefToJsonArray(flyoutDef) + .forEach((item) => { + this.getAvailableBlocks(item, allBlocks); + }); + } else if ('contents' in schema) { schema.contents.forEach((contents) => { this.getAvailableBlocks(contents, allBlocks); }); @@ -181,21 +219,32 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { : []; 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', + ? Blockly.Msg['TOOLBOX_SEARCH_PROMPT'] + : Blockly.Msg['TOOLBOX_SEARCH_NO_RESULTS'], }); } + + if (this.parentToolbox_.getSelectedItem() !== this) { + return; + } + + if (oldCount !== newCount) { + Blockly.utils.aria.announceDynamicAriaState( + newCount === 1 + ? Blockly.Msg['TOOLBOX_SEARCH_RESULT_COUNT_ONE'] + : Blockly.Msg['TOOLBOX_SEARCH_RESULT_COUNT'].replace( + '%1', + String(newCount), + ), + ); + } + this.parentToolbox_.refreshSelection(); } @@ -204,10 +253,65 @@ export class ToolboxSearchCategory extends Blockly.ToolboxCategory { */ override dispose() { super.dispose(); + for (const event of this.boundEvents) { + Blockly.browserEvents.unbind(event); + } + this.boundEvents.length = 0; + if (this.onChangeWrapper) { + this.workspace_.removeChangeListener(this.onChangeWrapper); + this.onChangeWrapper = undefined; + } Blockly.ShortcutRegistry.registry.unregister( ToolboxSearchCategory.START_SEARCH_SHORTCUT, ); } + + /** + * Rebuilds the search index when the workspace changes in a way that alters + * what a dynamic toolbox category offers, such as creating or renaming a + * variable or a procedure. + * + * @param event The change that occurred on the workspace. + */ + private handleWorkspaceChange(event: Blockly.Events.Abstract) { + if (event.isUiEvent) return; + // This works off of the assumption that these events don't typically change + // what a dynamic category contains. Apps that need to rebuild the index + // manually can do so by firing a different workspace event. + if ( + event.type === Blockly.Events.BLOCK_MOVE || + event.type === Blockly.Events.BLOCK_FIELD_INTERMEDIATE_CHANGE || + event.type.startsWith('comment_') + ) { + return; + } + if (this.refreshBlockSearcher()) this.matchBlocks(); + } + + /** + * Rebuilds the BlockSearcher index if the available blocks have changed. + * + * @returns True if the index was rebuilt. + */ + private refreshBlockSearcher(): boolean { + const availableBlocks = new Set(); + this.workspace_.options.languageTree?.contents?.forEach((item) => + this.getAvailableBlocks(item, availableBlocks), + ); + + const blocks = [...availableBlocks]; + const snapshot = JSON.stringify([ + blocks, + this.workspace_ + .getVariableMap() + .getAllVariables() + .map((v) => v.getName()), + ]); + if (snapshot === this.indexedBlocks) return false; + this.indexedBlocks = snapshot; + this.blockSearcher.indexBlocks(blocks); + return true; + } } // Make the clear button clickable in Safari. diff --git a/packages/plugins/toolbox-search/test/index.ts b/packages/plugins/toolbox-search/test/index.ts index 2d3068ad514..fcff7dfa590 100644 --- a/packages/plugins/toolbox-search/test/index.ts +++ b/packages/plugins/toolbox-search/test/index.ts @@ -9,7 +9,7 @@ */ import * as Blockly from 'blockly'; -import {toolboxCategories, createPlayground} from '@blockly/dev-tools'; +import {createPlayground} from '@blockly/dev-tools'; import '../src/toolbox_search'; /** @@ -23,16 +23,227 @@ function createWorkspace( blocklyDiv: HTMLElement, options: Blockly.BlocklyOptions, ): Blockly.WorkspaceSvg { - return Blockly.inject(blocklyDiv, options); + const workspace = Blockly.inject(blocklyDiv, options); + workspace.getVariableMap().createVariable('alpha'); + workspace.getVariableMap().createVariable('beta'); + return workspace; } +const toolbox = { + kind: 'categoryToolbox', + contents: [ + { + kind: 'category', + name: 'Logic', + categorystyle: 'logic_category', + contents: [ + {kind: 'block', type: 'controls_if'}, + {kind: 'block', type: 'logic_compare', fields: {OP: 'EQ'}}, + {kind: 'block', type: 'logic_operation', fields: {OP: 'AND'}}, + {kind: 'block', type: 'logic_negate'}, + {kind: 'block', type: 'logic_boolean', fields: {BOOL: 'TRUE'}}, + ], + }, + { + kind: 'category', + name: 'Loops', + categorystyle: 'loop_category', + contents: [ + { + kind: 'block', + type: 'controls_repeat_ext', + inputs: {TIMES: {shadow: {type: 'math_number', fields: {NUM: 250}}}}, + }, + {kind: 'block', type: 'controls_whileUntil', fields: {MODE: 'WHILE'}}, + {kind: 'block', type: 'controls_forEach'}, + { + kind: 'block', + type: 'controls_flow_statements', + fields: {FLOW: 'BREAK'}, + }, + ], + }, + { + kind: 'category', + name: 'Math', + categorystyle: 'math_category', + contents: [ + {kind: 'block', type: 'math_number', fields: {NUM: 42}}, + { + kind: 'block', + type: 'math_arithmetic', + fields: {OP: 'ADD'}, + inputs: { + A: {shadow: {type: 'math_number', fields: {NUM: 1}}}, + B: {shadow: {type: 'math_number', fields: {NUM: 1}}}, + }, + }, + { + kind: 'block', + type: 'math_round', + fields: {OP: 'ROUND'}, + inputs: {NUM: {shadow: {type: 'math_number', fields: {NUM: 3.1}}}}, + }, + { + kind: 'block', + type: 'math_modulo', + inputs: { + DIVIDEND: {shadow: {type: 'math_number', fields: {NUM: 64}}}, + DIVISOR: {shadow: {type: 'math_number', fields: {NUM: 10}}}, + }, + }, + ], + }, + { + kind: 'category', + name: 'Text', + categorystyle: 'text_category', + contents: [ + {kind: 'block', type: 'text', fields: {TEXT: 'abracadabra'}}, + { + kind: 'block', + type: 'text_print', + inputs: { + TEXT: {shadow: {type: 'text', fields: {TEXT: 'hello world'}}}, + }, + }, + {kind: 'block', type: 'text_join'}, + { + kind: 'block', + type: 'text_length', + inputs: {VALUE: {shadow: {type: 'text', fields: {TEXT: 'abc'}}}}, + }, + { + kind: 'block', + type: 'text_changeCase', + fields: {CASE: 'UPPERCASE'}, + inputs: {TEXT: {shadow: {type: 'text', fields: {TEXT: 'abc'}}}}, + }, + { + kind: 'block', + type: 'text_append', + inputs: {TEXT: {shadow: {type: 'text', fields: {TEXT: '!'}}}}, + }, + ], + }, + { + kind: 'category', + name: 'Lists', + categorystyle: 'list_category', + contents: [ + {kind: 'block', type: 'lists_create_with'}, + { + kind: 'block', + type: 'lists_sort', + fields: {TYPE: 'NUMERIC', DIRECTION: '1'}, + }, + { + kind: 'block', + type: 'lists_split', + fields: {MODE: 'SPLIT'}, + inputs: {DELIM: {shadow: {type: 'text', fields: {TEXT: ','}}}}, + }, + { + kind: 'block', + type: 'lists_getIndex', + fields: {MODE: 'GET', WHERE: 'FROM_START'}, + }, + { + kind: 'block', + type: 'lists_getSublist', + fields: {WHERE1: 'FROM_START', WHERE2: 'FROM_START'}, + }, + ], + }, + {kind: 'sep'}, + { + kind: 'category', + name: 'Variables', + categorystyle: 'variable_category', + custom: 'VARIABLE', + }, + { + kind: 'category', + name: 'Functions', + categorystyle: 'procedure_category', + custom: 'PROCEDURE', + }, + {kind: 'sep'}, + { + kind: 'category', + name: 'Snippets', + categorystyle: 'logic_category', + contents: [ + { + kind: 'block', + type: 'text_print', + inputs: {TEXT: {block: {type: 'variables_get'}}}, + }, + { + kind: 'block', + type: 'controls_if', + inputs: { + IF0: { + block: { + type: 'logic_compare', + fields: {OP: 'GT'}, + inputs: { + A: {block: {type: 'variables_get'}}, + B: {shadow: {type: 'math_number', fields: {NUM: 100}}}, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'text_print', + inputs: { + TEXT: { + block: { + type: 'lists_getSublist', + fields: {WHERE1: 'FROM_END'}, + inputs: { + LIST: { + block: { + type: 'variables_get', + fields: {VAR: {name: 'beta'}}, + }, + }, + AT1: {shadow: {type: 'math_number'}}, + AT2: {shadow: {type: 'math_number', fields: {NUM: 3}}}, + }, + }, + }, + }, + }, + { + kind: 'block', + type: 'text_print', + inputs: { + TEXT: { + block: { + type: 'lists_sort', + fields: {TYPE: 'NUMERIC', DIRECTION: '1'}, + inputs: { + LIST: { + block: { + type: 'variables_get', + fields: {VAR: {name: 'beta'}}, + }, + }, + }, + }, + }, + }, + }, + ], + }, + {kind: 'search', name: 'Search', contents: []}, + ], +}; + document.addEventListener('DOMContentLoaded', function () { - const toolbox = {...toolboxCategories}; - toolbox['contents'].push({ - kind: 'search', - name: 'Search', - contents: [], - }); const defaultOptions: Blockly.BlocklyOptions = { toolbox, }; diff --git a/packages/plugins/toolbox-search/test/tests.mocha.js b/packages/plugins/toolbox-search/test/tests.mocha.js index 950eaa12fba..5dfd8f224de 100644 --- a/packages/plugins/toolbox-search/test/tests.mocha.js +++ b/packages/plugins/toolbox-search/test/tests.mocha.js @@ -1,5 +1,6 @@ import {assert} from 'chai'; import * as Blockly from 'blockly'; +import * as sinon from 'sinon'; import {ToolboxSearchCategory} from '../src/toolbox_search'; import {BlockSearcher} from '../src/block_searcher'; @@ -14,9 +15,134 @@ suite('Toolbox search', () => { }); }); +suite('ToolboxSearchCategory', () => { + /** + * @param {!Blockly.WorkspaceSvg} workspace The workspace to inspect. + * @returns {!Array} The types of the blocks now in the flyout. + */ + function flyoutBlockTypes(workspace) { + return workspace + .getFlyout() + .getWorkspace() + .getTopBlocks(false) + .map((block) => block.type); + } + + setup(function () { + this.jsdomCleanup = require('jsdom-global')( + '
', + ); + this.clock = sinon.useFakeTimers(); + this.workspace = Blockly.inject('blocklyDiv', { + media: 'media/', + toolbox: { + kind: 'categoryToolbox', + contents: [ + { + kind: 'category', + name: 'Logic', + contents: [{kind: 'block', type: 'controls_if'}], + }, + {kind: 'category', name: 'Variables', custom: 'VARIABLE'}, + {kind: 'search', name: 'Search', contents: []}, + ], + }, + }); + // See https://github.com/RaspberryPiFoundation/blockly-samples/issues/2528. + global.SVGElement = window.SVGElement; + global.requestAnimationFrame = (callback) => setTimeout(callback, 0); + this.searchCategory = this.workspace + .getToolbox() + .getToolboxItems() + .find((item) => item instanceof ToolboxSearchCategory); + + /** + * Types into the search field the way a user would, so the category's own + * input listener drives the flyout. + * @param {string} query The text to search for. + */ + this.search = (query) => { + const field = this.searchCategory.searchField; + this.searchCategory + .getParentToolbox() + .setSelectedItem(this.searchCategory); + field.value = query; + field.dispatchEvent(new window.Event('input')); + this.clock.runAll(); + }; + }); + + teardown(function () { + this.workspace.dispose(); + this.clock.runAll(); + this.clock.restore(); + this.jsdomCleanup(); + }); + + test('shows matching blocks in the flyout', function () { + this.search('controls if'); + + assert.deepEqual(flyoutBlockTypes(this.workspace), ['controls_if']); + }); + + test('updates flyout blocks to the variable that was searched for', function () { + this.workspace.getVariableMap().createVariable('score'); + this.clock.runAll(); + + this.search('score'); + + const blocks = this.workspace + .getFlyout() + .getWorkspace() + .getTopBlocks(false); + assert.deepEqual( + blocks.map((block) => block.type), + ['variables_set', 'math_change', 'variables_get'], + ); + blocks.forEach((block) => { + assert.equal(block.getVarModels()[0].getName(), 'score'); + }); + }); + + test('keeps the flyout current as variables are created', function () { + this.search('score'); + assert.isEmpty(flyoutBlockTypes(this.workspace)); + + this.workspace.getVariableMap().createVariable('score'); + this.clock.runAll(); + + assert.include(flyoutBlockTypes(this.workspace), 'variables_get'); + }); +}); + suite('BlockSearcher', () => { + let workspace; + + setup(() => { + workspace = new Blockly.Workspace(); + }); + + teardown(() => { + workspace.dispose(); + }); + + /** + * Creates the named variables on the workspace and returns the block + * definitions the VARIABLE category would create for them. + * @param {!Array} names The variables to create. + * @returns {!Array} A setter, a change block and one getter each. + */ + function createVariableBlocks(names) { + names.forEach((name) => workspace.getVariableMap().createVariable(name)); + return Blockly.Variables.jsonFlyoutCategoryBlocks( + workspace, + workspace.getVariableMap().getVariablesOfType(''), + true, + ); + } + test('generateTrigrams handles empty and short input', () => { - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); const generateTrigrams = searcher.generateTrigrams.bind(searcher); assert.deepEqual(generateTrigrams(''), []); @@ -25,7 +151,7 @@ suite('BlockSearcher', () => { }); test('indexes the default value of dropdown fields', () => { - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); const blocks = [ { kind: 'block', @@ -49,7 +175,7 @@ suite('BlockSearcher', () => { }); test('is not case-sensitive', () => { - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); const listCreateWithBlock = { kind: 'block', type: 'lists_create_with', @@ -67,7 +193,7 @@ suite('BlockSearcher', () => { }); test('requires the final trigram when matching longer queries', () => { - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); const mathConstrainBlock = { kind: 'block', type: 'math_constrain', @@ -93,7 +219,7 @@ suite('BlockSearcher', () => { ]); } - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); const blockInfo = { kind: 'block', type: 'searcher_underscore_block', @@ -121,7 +247,7 @@ suite('BlockSearcher', () => { ]); } - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); const blockA = {kind: 'block', type: 'searcher_charlie'}; const blockB = {kind: 'block', type: 'searcher_delta'}; @@ -172,21 +298,27 @@ suite('BlockSearcher', () => { ]); } - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); const blockInfo = {kind: 'block', type: 'searcher_dropdown_alt'}; searcher.indexBlocks([blockInfo]); assert.sameMembers(searcher.blockTypesMatching('sunny'), [blockInfo]); - assert.sameMembers(searcher.blockTypesMatching('cloudy'), [blockInfo]); + // 'cloudy' wasn't the selected option, but it should be set with the matching option if found. + const cloudyMatches = searcher.blockTypesMatching('cloudy'); + assert.lengthOf(cloudyMatches, 1); + assert.deepEqual(cloudyMatches[0], { + ...blockInfo, + fields: {WEATHER: 'CLOUD'}, + }); }); test('returns an empty list when no matches are found', () => { - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); assert.isEmpty(searcher.blockTypesMatching('abc123')); }); test('returns preset blocks', () => { - const searcher = new BlockSearcher(); + const searcher = new BlockSearcher(workspace); const blocks = [ { kind: 'block', @@ -216,4 +348,182 @@ suite('BlockSearcher', () => { const matches = searcher.blockTypesMatching('replace'); assert.sameMembers(matches, [blocks[0]]); }); + + test('indexes field values from the block definition', () => { + const searcher = new BlockSearcher(workspace); + const numberBlock = { + kind: 'block', + type: 'math_number', + fields: {NUM: 250}, + }; + const printBlock = { + kind: 'block', + type: 'text_print', + inputs: {TEXT: {shadow: {type: 'text', fields: {TEXT: 'abc'}}}}, + }; + searcher.indexBlocks([numberBlock, printBlock]); + + assert.sameMembers(searcher.blockTypesMatching('250'), [numberBlock]); + // The value lives on a shadow block, not on the block itself. + assert.sameMembers(searcher.blockTypesMatching('abc'), [printBlock]); + }); + + test('binds variable blocks to every matching variable', () => { + const searcher = new BlockSearcher(workspace); + searcher.indexBlocks(createVariableBlocks(['alpha', 'alphabet', 'beta'])); + + const matches = searcher.blockTypesMatching('alpha'); + assert.sameMembers( + matches.map((match) => `${match.type}(${match.fields.VAR.name})`), + [ + 'variables_set(alpha)', + 'variables_set(alphabet)', + 'math_change(alpha)', + 'math_change(alphabet)', + 'variables_get(alpha)', + 'variables_get(alphabet)', + ], + ); + }); + + test('does not index variable rename and delete options', () => { + const searcher = new BlockSearcher(workspace); + searcher.indexBlocks(createVariableBlocks(['alpha'])); + + assert.isEmpty(searcher.blockTypesMatching('rename')); + assert.isEmpty(searcher.blockTypesMatching('delete the')); + }); + + test('sets dropdowns to the option that matched', () => { + const searcher = new BlockSearcher(workspace); + const sortBlock = {kind: 'block', type: 'lists_sort'}; + searcher.indexBlocks([sortBlock]); + + // 'numeric' is already selected, so the indexed block is returned as-is. + assert.sameMembers(searcher.blockTypesMatching('numeric'), [sortBlock]); + assert.sameDeepMembers( + searcher.blockTypesMatching('alphabetic').map((match) => match.fields), + [ + {TYPE: 'TEXT', DIRECTION: '1'}, + {TYPE: 'IGNORE_CASE', DIRECTION: '1'}, + ], + ); + }); + + test('varies one dropdown at a time', () => { + const searcher = new BlockSearcher(workspace); + // Both WHERE1 and WHERE2 offer '# from end'. + searcher.indexBlocks([{kind: 'block', type: 'lists_getSublist'}]); + + assert.sameDeepMembers( + searcher.blockTypesMatching('from end').map((match) => match.fields), + [ + {WHERE1: 'FROM_END', WHERE2: 'FROM_START'}, + {WHERE1: 'FROM_START', WHERE2: 'FROM_END'}, + ], + ); + }); + + test('indexes procedure names from extra state', () => { + const searcher = new BlockSearcher(workspace); + const callBlock = { + kind: 'block', + type: 'procedures_callnoreturn', + extraState: {name: 'draw sprites', params: []}, + }; + searcher.indexBlocks([callBlock]); + + assert.sameMembers(searcher.blockTypesMatching('draw sprites'), [ + callBlock, + ]); + }); + + test('does not match trigrams pooled from different strings', () => { + const searcher = new BlockSearcher(workspace); + // 'controls flow statements' supplies 'tem' and 'next iteration' supplies + // 'ite', but the block never says 'item'. + searcher.indexBlocks([{kind: 'block', type: 'controls_flow_statements'}]); + + assert.isEmpty(searcher.blockTypesMatching('item')); + }); + + test('replaces the previous index when reindexing', () => { + const searcher = new BlockSearcher(workspace); + searcher.indexBlocks([{kind: 'block', type: 'text_print'}]); + + // Reindexing should forget the previous pass entirely, not add to it. + const negate = {kind: 'block', type: 'logic_negate'}; + searcher.indexBlocks([negate]); + + assert.isEmpty(searcher.blockTypesMatching('print')); + assert.sameMembers(searcher.blockTypesMatching('not'), [negate]); + }); + + test('sets a dropdown on a child block without touching the root', () => { + const searcher = new BlockSearcher(workspace); + searcher.indexBlocks([ + { + kind: 'block', + type: 'controls_if', + inputs: {IF0: {block: {type: 'logic_compare', fields: {OP: 'GT'}}}}, + }, + ]); + + const matches = searcher.blockTypesMatching('≠'); + assert.lengthOf(matches, 1); + assert.equal(matches[0].inputs.IF0.block.fields.OP, 'NEQ'); + }); + + test('updates a variable field on a child block', () => { + ['item', 'score'].forEach((name) => + workspace.getVariableMap().createVariable(name), + ); + const searcher = new BlockSearcher(workspace); + searcher.indexBlocks([ + { + kind: 'block', + type: 'text_print', + inputs: {TEXT: {block: {type: 'variables_get'}}}, + }, + ]); + + const matches = searcher.blockTypesMatching('score'); + assert.deepEqual( + matches.map((match) => match.inputs.TEXT.block.fields.VAR.name), + ['score'], + ); + }); + + test('does not rewrite a sibling dropdown to text already showing', () => { + const searcher = new BlockSearcher(workspace); + const sublist = { + kind: 'block', + type: 'lists_getSublist', + fields: {WHERE1: 'FROM_END', WHERE2: 'LAST'}, + }; + searcher.indexBlocks([sublist]); + + // WHERE1 already reads '# from end', so WHERE2 is left alone. + assert.sameMembers(searcher.blockTypesMatching('from end'), [sublist]); + }); + + test('leaves a block alone when the query is already visible', () => { + ['alpha', 'beta'].forEach((name) => + workspace.getVariableMap().createVariable(name), + ); + const searcher = new BlockSearcher(workspace); + const forEach = { + kind: 'block', + type: 'controls_forEach', + fields: {VAR: {name: 'alpha'}}, + inputs: { + LIST: {block: {type: 'variables_get', fields: {VAR: {name: 'beta'}}}}, + }, + }; + searcher.indexBlocks([forEach]); + + // 'alpha' is already on one of the two variable fields, so neither is + // updated. + assert.sameMembers(searcher.blockTypesMatching('alpha'), [forEach]); + }); });