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..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. @@ -196,4 +197,8 @@ export abstract class Icon implements IIcon { getSourceBlock(): Block { return this.sourceBlock; } + + showContextMenu(e: PointerEvent) { + (this.getSourceBlock() as BlockSvg).showContextMenu(e); + } } diff --git a/packages/blockly/tests/mocha/icon_test.js b/packages/blockly/tests/mocha/icon_test.js index ba1b7116065..35117e3049b 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('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)); + } + }); + }); });