Files
blockly/core/contextmenu.ts
Ben Henning 4074cee31b feat!: Make everything ISelectable focusable (#9004)
* feat!: Make bubbles, comments, and icons focusable

* feat!: Make ISelectable and ICopyable focusable.

* feat: Consolidate selection calls.

Now everything is based on focus with selection only being used as a
proxy.

* feat: Invert responsibility for setSelected().

Now setSelected() is only for quasi-external use.

* feat: Push up shadow check to getters.

Needed new common-level helper.

* chore: Lint fixes.

* feat!: Allow IFocusableNode to disable focus.

* chore: post-merge lint fixes

* fix: Fix tests + text bubble focusing.

This fixed then regressed a circular dependency causing the node and
advanced compilation steps to fail. This investigation is ongoing.

* fix: Clean up & fix imports.

This ensures the node and advanced compilation test steps now pass.

* fix: Lint fixes + revert commented out logic.

* chore: Remove unnecessary cast.

Addresses reviewer comment.

* fix: Some issues and a bunch of clean-ups.

This addresses a bunch of review comments, and fixes selecting workspace
comments.

* chore: Lint fix.

* fix: Remove unnecessary shadow consideration.

* chore: Revert import.

* chore: Some doc updates & added a warn statement.
2025-05-09 08:16:14 -07:00

296 lines
8.5 KiB
TypeScript

/**
* @license
* Copyright 2011 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.ContextMenu
import type {Block} from './block.js';
import type {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import {config} from './config.js';
import type {
ContextMenuOption,
LegacyContextMenuOption,
} from './contextmenu_registry.js';
import {EventType} from './events/type.js';
import * as eventUtils from './events/utils.js';
import {getFocusManager} from './focus_manager.js';
import {Menu} from './menu.js';
import {MenuSeparator} from './menu_separator.js';
import {MenuItem} from './menuitem.js';
import * as serializationBlocks from './serialization/blocks.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import {Rect} from './utils/rect.js';
import * as svgMath from './utils/svg_math.js';
import * as WidgetDiv from './widgetdiv.js';
import type {WorkspaceSvg} from './workspace_svg.js';
import * as Xml from './xml.js';
/**
* Which block is the context menu attached to?
*/
let currentBlock: Block | null = null;
const dummyOwner = {};
/**
* Gets the block the context menu is currently attached to.
* It is not recommended that you use this function; instead,
* use the scope object passed to the context menu callback.
*
* @returns The block the context menu is attached to.
*/
export function getCurrentBlock(): Block | null {
return currentBlock;
}
/**
* Sets the block the context menu is currently attached to.
*
* @param block The block the context menu is attached to.
*/
export function setCurrentBlock(block: Block | null) {
currentBlock = block;
}
/**
* Menu object.
*/
let menu_: Menu | null = null;
/**
* Construct the menu based on the list of options and show the menu.
*
* @param menuOpenEvent Event that caused the menu to open.
* @param options Array of menu options.
* @param rtl True if RTL, false if LTR.
* @param workspace The workspace associated with the context menu, if any.
* @param location The screen coordinates at which to show the menu.
*/
export function show(
menuOpenEvent: Event,
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
workspace?: WorkspaceSvg,
location?: Coordinate,
) {
WidgetDiv.show(dummyOwner, rtl, dispose, workspace);
if (!options.length) {
hide();
return;
}
if (!location) {
if (menuOpenEvent instanceof PointerEvent) {
location = new Coordinate(menuOpenEvent.clientX, menuOpenEvent.clientY);
} else {
// We got a keyboard event that didn't tell us where to open the menu, so just guess
console.warn('Context menu opened with keyboard but no location given');
location = new Coordinate(0, 0);
}
}
const menu = populate_(options, rtl, menuOpenEvent, location);
menu_ = menu;
position_(menu, rtl, location);
// 1ms delay is required for focusing on context menus because some other
// mouse event is still waiting in the queue and clears focus.
setTimeout(function () {
menu.focus();
}, 1);
currentBlock = null; // May be set by Blockly.Block.
}
/**
* Create the context menu object and populate it with the given options.
*
* @param options Array of menu options.
* @param rtl True if RTL, false if LTR.
* @param menuOpenEvent The event that triggered the context menu to open.
* @param location The screen coordinates at which to show the menu.
* @returns The menu that will be shown on right click.
*/
function populate_(
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
menuOpenEvent: Event,
location: Coordinate,
): Menu {
/* Here's what one option object looks like:
{text: 'Make It So',
enabled: true,
callback: Blockly.MakeItSo}
*/
const menu = new Menu();
menu.setRole(aria.Role.MENU);
for (let i = 0; i < options.length; i++) {
const option = options[i];
if (option.separator) {
menu.addChild(new MenuSeparator());
continue;
}
const menuItem = new MenuItem(option.text);
menuItem.setRightToLeft(rtl);
menuItem.setRole(aria.Role.MENUITEM);
menu.addChild(menuItem);
menuItem.setEnabled(option.enabled);
if (option.enabled) {
const actionHandler = function (p1: MenuItem, menuSelectEvent: Event) {
hide();
requestAnimationFrame(() => {
setTimeout(() => {
// If .scope does not exist on the option, then the callback
// will not be expecting a scope parameter, so there should be
// no problems. Just assume it is a ContextMenuOption and we'll
// pass undefined if it's not.
option.callback(
(option as ContextMenuOption).scope,
menuOpenEvent,
menuSelectEvent,
location,
);
}, 0);
});
};
menuItem.onAction(actionHandler, {});
}
}
return menu;
}
/**
* Add the menu to the page and position it correctly.
*
* @param menu The menu to add and position.
* @param rtl True if RTL, false if LTR.
* @param location The location at which to anchor the menu.
*/
function position_(menu: Menu, rtl: boolean, location: Coordinate) {
// Record windowSize and scrollOffset before adding menu.
const viewportBBox = svgMath.getViewportBBox();
// This one is just a point, but we'll pretend that it's a rect so we can use
// some helper functions.
const anchorBBox = new Rect(
location.y + viewportBBox.top,
location.y + viewportBBox.top,
location.x + viewportBBox.left,
location.x + viewportBBox.left,
);
createWidget_(menu);
const menuSize = menu.getSize();
if (rtl) {
anchorBBox.left += menuSize.width;
anchorBBox.right += menuSize.width;
viewportBBox.left += menuSize.width;
viewportBBox.right += menuSize.width;
}
WidgetDiv.positionWithAnchor(viewportBBox, anchorBBox, menuSize, rtl);
// Calling menuDom.focus() has to wait until after the menu has been placed
// correctly. Otherwise it will cause a page scroll to get the misplaced menu
// in view. See issue #1329.
menu.focus();
}
/**
* Create and render the menu widget inside Blockly's widget div.
*
* @param menu The menu to add to the widget div.
*/
function createWidget_(menu: Menu) {
const div = WidgetDiv.getDiv();
if (!div) {
throw Error('Attempting to create a context menu when widget div is null');
}
const menuDom = menu.render(div);
dom.addClass(menuDom, 'blocklyContextMenu');
// Prevent system context menu when right-clicking a Blockly context menu.
browserEvents.conditionalBind(
menuDom as EventTarget,
'contextmenu',
null,
haltPropagation,
);
// Focus only after the initial render to avoid issue #1329.
menu.focus();
}
/**
* Halts the propagation of the event without doing anything else.
*
* @param e An event.
*/
function haltPropagation(e: Event) {
// This event has been handled. No need to bubble up to the document.
e.preventDefault();
e.stopPropagation();
}
/**
* Hide the context menu.
*/
export function hide() {
WidgetDiv.hideIfOwner(dummyOwner);
currentBlock = null;
}
/**
* Dispose of the menu.
*/
export function dispose() {
if (menu_) {
menu_.dispose();
menu_ = null;
}
}
/**
* Create a callback function that creates and configures a block,
* then places the new block next to the original and returns it.
*
* @param block Original block.
* @param state XML or JSON object representation of the new block.
* @returns Function that creates a block.
*/
export function callbackFactory(
block: Block,
state: Element | serializationBlocks.State,
): () => BlockSvg {
return () => {
eventUtils.disable();
let newBlock: BlockSvg;
try {
if (state instanceof Element) {
newBlock = Xml.domToBlockInternal(state, block.workspace!) as BlockSvg;
} else {
newBlock = serializationBlocks.appendInternal(
state,
block.workspace,
) as BlockSvg;
}
// Move the new block next to the old block.
const xy = block.getRelativeToSurfaceXY();
if (block.RTL) {
xy.x -= config.snapRadius;
} else {
xy.x += config.snapRadius;
}
xy.y += config.snapRadius * 2;
newBlock.moveBy(xy.x, xy.y);
} finally {
eventUtils.enable();
}
if (eventUtils.isEnabled() && !newBlock.isShadow()) {
eventUtils.fire(new (eventUtils.get(EventType.BLOCK_CREATE))(newBlock));
}
getFocusManager().focusNode(newBlock);
return newBlock;
};
}