Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/blockly/msg/json/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
7 changes: 6 additions & 1 deletion packages/blockly/msg/json/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
18 changes: 17 additions & 1 deletion packages/blockly/msg/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
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';
3 changes: 3 additions & 0 deletions packages/plugins/toolbox-search/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
"@blockly/dev-scripts": "^13.1.0",
"@blockly/dev-tools": "^13.1.0",
"chai": "^6.2.2",
"jsdom": "^30.0.1",
"jsdom-global": "^3.0.2",
"sinon": "^22.1.0",
"typescript": "^6.0.3"
},
"peerDependencies": {
Expand Down
188 changes: 172 additions & 16 deletions packages/plugins/toolbox-search/src/block_searcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@

import * as Blockly from 'blockly/core';

interface DropdownOption {
fieldName: string;
label: string;
value: string;
selected: boolean;
}

/**
* A class that provides methods for indexing and searching blocks.
*/
Expand All @@ -15,6 +22,23 @@ export class BlockSearcher {
Set<Blockly.utils.toolbox.BlockInfo>
>();

// 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<Blockly.utils.toolbox.BlockInfo, string[]>();
// A map of blocks to the options of their dropdown fields, used to generate
// variants of blocks with different dropdown values.
private dropdownOptions = new Map<
Blockly.utils.toolbox.BlockInfo,
DropdownOption[]
>();
// A map of blocks to the names of their variable fields, used to generate
// variants of blocks with different variable values.
private variableFields = new Map<Blockly.utils.toolbox.BlockInfo, string[]>();
// All workspace variables, sorted by name, updated when blocks are indexed.
private workspaceVariables: Array<
Blockly.IVariableModel<Blockly.IVariableState>
> = [];

/**
* Populates the cached map of trigrams to the blocks they correspond to.
*
Expand All @@ -24,21 +48,76 @@ export class BlockSearcher {
* itself.
*
* @param blockInfos A list of blocks to index.
* @param workspace The workspace source of truth for variables. This is
* used to index variable names and update variable fields.
*/
indexBlocks(blockInfos: Blockly.utils.toolbox.BlockInfo[]) {
indexBlocks(
blockInfos: Blockly.utils.toolbox.BlockInfo[],
workspace: Blockly.Workspace,
) {
this.blockText.clear();
this.dropdownOptions.clear();
this.trigramsToBlocks.clear();
this.variableFields.clear();
this.workspaceVariables = workspace
.getVariableMap()
.getAllVariables()
.sort(Blockly.Variables.compareByName);

const blockCreationWorkspace = new Blockly.Workspace();
blockInfos.forEach((blockInfo) => {
const type = blockInfo.type;
if (!type || type === '') return;
const block = blockCreationWorkspace.newBlock(type);
blockCreationWorkspace.clear();
this.workspaceVariables.forEach((variable) =>
blockCreationWorkspace
.getVariableMap()
.createVariable(variable.getName(), variable.getType()),
);
const block = Blockly.serialization.blocks.append(
blockInfo as Blockly.serialization.blocks.State,
blockCreationWorkspace,
);
this.indexBlockText(type.replaceAll('_', ' '), blockInfo);
block.inputList.forEach((input) => {
input.fieldRow.forEach((field) => {
this.indexDropdownOption(field, blockInfo);
this.indexBlockText(field.getText(), blockInfo);
const variableFieldNames: string[] = [];

// Index the text of every field on the block and its descendants, and record
// the names of any variable fields for later use in generating variants.
block.getDescendants(false).forEach((descendantBlock) => {
descendantBlock.inputList.forEach((input) => {
input.fieldRow.forEach((field) => {
if (field instanceof Blockly.FieldVariable) {
this.indexBlockText(field.getText(), blockInfo);
// If the current variable is one of the workspace variables, record
// the field name for later use in generating variants.
if (
descendantBlock === block &&
field.name &&
this.workspaceVariables.some(
(v) => v.getName() === field.getText(),
)
) {
variableFieldNames.push(field.name);
}
} else {
// Index the text of the dropdown option and the block.
this.indexDropdownOption(field, blockInfo);
this.indexBlockText(field.getText(), blockInfo);
}
});
});
});
if (variableFieldNames.length) {
// Index all workspace variable names for the block, so that a search for any of them
// will return the block, and record the names of the variable fields for later use
// in generating variants.
this.variableFields.set(blockInfo, variableFieldNames);
this.workspaceVariables.forEach((variable) => {
this.indexBlockText(variable.getName(), blockInfo);
});
}
});
blockCreationWorkspace.dispose();
}

/**
Expand All @@ -51,15 +130,49 @@ export class BlockSearcher {
field: Blockly.Field,
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;
const options = this.dropdownOptions.get(block) ?? [];
options.push({
fieldName: field.name,
label: text.toLowerCase(),
value,
selected: value === field.getValue(),
});
this.dropdownOptions.set(block, options);
});
}

/**
* Returns a list of variants of the given block with different dropdown values
*
* @param info The block to vary.
* @param options The options whose labels matched the query.
* @returns One block per matching option.
*/
private createMatchingBlockVariants(
info: Blockly.utils.toolbox.BlockInfo,
options: Array<{fieldName: string; value: string; selected: boolean}>,
): Blockly.utils.toolbox.BlockInfo[] {
if (!options.length) return [info];
// One variant per matching option, each differing in a single field, so
// two matching dropdowns give two results rather than four.
return options.map((option) =>
option.selected
? info
: {...info, fields: {...info.fields, [option.fieldName]: option.value}},
);
}

/**
Expand All @@ -69,7 +182,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 (
Expand All @@ -82,6 +195,47 @@ export class BlockSearcher {
})
.values(),
];

const searchTerm = query.toLowerCase();
const matches = candidates.filter((block) =>
this.blockText.get(block)?.some((text) => text.includes(searchTerm)),
);

const matchedVariables = this.workspaceVariables.filter((v) =>
v.getName().toLowerCase().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<string, Blockly.utils.toolbox.BlockInfo>();
for (const match of matches) {
const variableFieldNames = this.variableFields.get(match);
const bound =
variableFieldNames && matchedVariables.length
? matchedVariables.map((variable) => ({
...match,
fields: {
...match.fields,
...Object.fromEntries(
variableFieldNames.map((name) => [
name,
{name: variable.getName(), type: variable.getType()},
]),
),
},
}))
: [match];

const options = (this.dropdownOptions.get(match) ?? []).filter((option) =>
option.label.includes(searchTerm),
);

for (const info of bound) {
for (const variant of this.createMatchingBlockVariants(info, options)) {
results.set(JSON.stringify(variant), variant);
}
}
}
return [...results.values()];
}

/**
Expand All @@ -92,6 +246,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) ??
Expand All @@ -116,7 +273,6 @@ export class BlockSearcher {
for (let start = 0; start <= normalizedInput.length - 3; start++) {
trigrams.push(normalizedInput.substring(start, start + 3));
}

return trigrams;
}

Expand Down
Loading
Loading