From 1176951a40a05a3eb1d651b2293a344945668734 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 22 Jan 2026 12:32:01 -0800 Subject: [PATCH 1/4] feat: Add support for displaying contextual menus on icons --- packages/blockly/core/block_svg.ts | 2 ++ packages/blockly/core/gesture.ts | 12 +++++++++--- packages/blockly/core/icons/icon.ts | 4 ++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/blockly/core/block_svg.ts b/packages/blockly/core/block_svg.ts index 83af5188e99..68c8823d6cb 100644 --- a/packages/blockly/core/block_svg.ts +++ b/packages/blockly/core/block_svg.ts @@ -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); } }; } diff --git a/packages/blockly/core/gesture.ts b/packages/blockly/core/gesture.ts index 64319e76b6e..59592fc3d89 100644 --- a/packages/blockly/core/gesture.ts +++ b/packages/blockly/core/gesture.ts @@ -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()); diff --git a/packages/blockly/core/icons/icon.ts b/packages/blockly/core/icons/icon.ts index f5f76603875..a36002eda2f 100644 --- a/packages/blockly/core/icons/icon.ts +++ b/packages/blockly/core/icons/icon.ts @@ -196,4 +196,8 @@ export abstract class Icon implements IIcon { getSourceBlock(): Block { return this.sourceBlock; } + + showContextMenu(e: PointerEvent) { + (this.getSourceBlock() as BlockSvg).showContextMenu(e); + } } From 91ad65c9b49291c7f2d5c6d35ed2613de8d2ceb3 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 22 Jan 2026 13:29:00 -0800 Subject: [PATCH 2/4] test: Add tests for contextual menus on icons --- packages/blockly/tests/mocha/icon_test.js | 62 +++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/blockly/tests/mocha/icon_test.js b/packages/blockly/tests/mocha/icon_test.js index ba1b7116065..1240280ef57 100644 --- a/packages/blockly/tests/mocha/icon_test.js +++ b/packages/blockly/tests/mocha/icon_test.js @@ -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() < TestIcon > '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)); + } + }); + }); }); From f4aba8ad2ab92eace0cbab6abf5a1fc75fe69570 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Thu, 22 Jan 2026 13:31:23 -0800 Subject: [PATCH 3/4] fix: Designate `Icon` as implementing `IContextMenu` --- packages/blockly/core/icons/icon.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/blockly/core/icons/icon.ts b/packages/blockly/core/icons/icon.ts index a36002eda2f..c8cfffaa4b6 100644 --- a/packages/blockly/core/icons/icon.ts +++ b/packages/blockly/core/icons/icon.ts @@ -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. From 1f610a1607659a61a3f5a993f77e359ff74e539c Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 27 Jan 2026 08:57:59 +0000 Subject: [PATCH 4/4] fix: Don't write Typescript in a JS file --- packages/blockly/tests/mocha/icon_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/tests/mocha/icon_test.js b/packages/blockly/tests/mocha/icon_test.js index 1240280ef57..35117e3049b 100644 --- a/packages/blockly/tests/mocha/icon_test.js +++ b/packages/blockly/tests/mocha/icon_test.js @@ -29,7 +29,7 @@ class TestIcon extends Blockly.icons.Icon { } getType() { - new Blockly.icons.IconType() < TestIcon > 'test'; + new Blockly.icons.IconType('test'); } }