feat: Add support for displaying contextual menus on icons (#9581)

* feat: Add support for displaying contextual menus on icons

* test: Add tests for contextual menus on icons

* fix: Designate `Icon` as implementing `IContextMenu`

* fix: Don't write Typescript in a JS file
This commit is contained in:
Aaron Dodson
2026-01-28 08:48:56 +00:00
committed by GitHub
parent 65bc2b5f18
commit 4657199ba6
4 changed files with 79 additions and 4 deletions

View File

@@ -1126,7 +1126,9 @@ export class BlockSvg
if (this.isDeadOrDying()) return;
const gesture = this.workspace.getGesture(e);
if (gesture) {
this.bringToFront();
gesture.setStartIcon(icon);
getFocusManager().focusNode(icon);
}
};
}

View File

@@ -764,7 +764,12 @@ export class Gesture {
this.setStartWorkspace(ws);
this.mostRecentEvent = e;
if (!this.targetBlock && !this.startBubble && !this.startComment) {
if (
!this.targetBlock &&
!this.startBubble &&
!this.startComment &&
!this.startIcon
) {
// Ensure the workspace is selected if nothing else should be. Note that
// this is focusNode() instead of focusTree() because if any active node
// is focused in the workspace it should be defocused.
@@ -1009,8 +1014,9 @@ export class Gesture {
* @internal
*/
setStartBlock(block: BlockSvg) {
// If the gesture already went through a bubble, don't set the start block.
if (!this.startBlock && !this.startBubble) {
// If the gesture already went through a block child, don't set the start
// block.
if (!this.startBlock && !this.startBubble && !this.startIcon) {
this.startBlock = block;
if (block.isInFlyout && block !== block.getRootBlock()) {
this.setTargetBlock(block.getRootBlock());

View File

@@ -7,6 +7,7 @@
import type {Block} from '../block.js';
import type {BlockSvg} from '../block_svg.js';
import * as browserEvents from '../browser_events.js';
import type {IContextMenu} from '../interfaces/i_contextmenu.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import {hasBubble} from '../interfaces/i_has_bubble.js';
import type {IIcon} from '../interfaces/i_icon.js';
@@ -26,7 +27,7 @@ import type {IconType} from './icon_types.js';
* block (such as warnings or comments) as opposed to fields, which provide
* "actual" information, related to how a block functions.
*/
export abstract class Icon implements IIcon {
export abstract class Icon implements IIcon, IContextMenu {
/**
* The position of this icon relative to its blocks top-start,
* in workspace units.
@@ -196,4 +197,8 @@ export abstract class Icon implements IIcon {
getSourceBlock(): Block {
return this.sourceBlock;
}
showContextMenu(e: PointerEvent) {
(this.getSourceBlock() as BlockSvg).showContextMenu(e);
}
}

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as Blockly from '../../build/src/core/blockly.js';
import {assert} from '../../node_modules/chai/index.js';
import {defineEmptyBlock} from './test_helpers/block_definitions.js';
import {MockIcon, MockSerializableIcon} from './test_helpers/icon_mocks.js';
@@ -11,6 +12,26 @@ import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
import {simulateClick} from './test_helpers/user_input.js';
class TestIcon extends Blockly.icons.Icon {
showContextMenu(e) {
const menuItems = [
{text: 'Test icon menu item', enabled: true, callback: () => {}},
];
Blockly.ContextMenu.show(
e,
menuItems,
false,
this.getSourceBlock().workspace,
this.workspaceLocation,
);
}
getType() {
new Blockly.icons.IconType('test');
}
}
suite('Icon', function () {
setup(function () {
@@ -366,4 +387,45 @@ suite('Icon', function () {
);
});
});
suite('Contextual menus', function () {
setup(function () {
this.workspace = Blockly.inject('blocklyDiv', {});
Blockly.icons.registry.register(
new Blockly.icons.IconType('test'),
TestIcon,
);
this.block = this.workspace.newBlock('empty_block');
this.block.initSvg();
});
test('are shown when icons are right clicked', function () {
const icon = new TestIcon(this.block);
this.block.addIcon(icon);
simulateClick(icon.getFocusableElement(), {button: 2});
const menu = document.querySelector('.blocklyContextMenu');
assert.isNotNull(menu);
assert.isTrue(menu.innerText.includes('Test icon menu item'));
});
test('default to the contextual menu of the parent block', function () {
this.block.setCommentText('hello there');
const icon = this.block.getIcon(Blockly.icons.IconType.COMMENT);
simulateClick(icon.getFocusableElement(), {button: 2});
const expectedItems =
Blockly.ContextMenuRegistry.registry.getContextMenuOptions({
block: this.block,
});
assert.isNotEmpty(expectedItems);
const menu = document.querySelector('.blocklyContextMenu');
for (const item of expectedItems) {
if (!item.text) continue;
assert.isTrue(menu.innerText.includes(item.text));
}
});
});
});