Skip to content
Merged
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.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": {
Expand Down
253 changes: 235 additions & 18 deletions packages/plugins/toolbox-search/src/block_searcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -15,6 +33,28 @@ 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 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.
*
Expand All @@ -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();
Comment thread
mikeharv marked this conversation as resolved.
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<Blockly.IVariableModel<Blockly.IVariableState>>,
) {
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);
}

/**
Expand All @@ -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 (
Expand All @@ -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<string, Blockly.utils.toolbox.BlockInfo>();
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()];
}

/**
Expand All @@ -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) ??
Expand All @@ -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;
}

Expand All @@ -133,4 +311,43 @@ export class BlockSearcher {
): Set<Blockly.utils.toolbox.BlockInfo> {
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;
}
}
Loading
Loading