* @license
* Copyright 2011 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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 * as dom from './utils/dom.js';
import type {
ContextMenuOption,
LegacyContextMenuOption,
} from './contextmenu_registry.js';
import * as eventUtils from './events/utils.js';
import {Menu} from './menu.js';
import {MenuItem} from './menuitem.js';
import * as aria from './utils/aria.js';
import {Rect} from './utils/rect.js';
import * as serializationBlocks from './serialization/blocks.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';
import * as common from './common.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.
*
* @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 e Mouse event.
* @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.
*/
export function show(
e: PointerEvent,
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
workspace?: WorkspaceSvg,
) {
WidgetDiv.show(dummyOwner, rtl, dispose, workspace);
if (!options.length) {
hide();
return;
}
const menu = populate_(options, rtl, e);
menu_ = menu;
position_(menu, e, rtl);
setTimeout(function () {
menu.focus();
}, 1);
currentBlock = null;
}
* 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 e The event that triggered the context menu to open.
* @returns The menu that will be shown on right click.
*/
function populate_(
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
e: PointerEvent,
): Menu {
{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];
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 () {
hide();
requestAnimationFrame(() => {
setTimeout(() => {
option.callback((option as ContextMenuOption).scope, e);
}, 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 e Mouse event for the right click that is making the context
* menu appear.
* @param rtl True if RTL, false if LTR.
*/
function position_(menu: Menu, e: Event, rtl: boolean) {
const viewportBBox = svgMath.getViewportBBox();
const mouseEvent = e as MouseEvent;
const anchorBBox = new Rect(
mouseEvent.clientY + viewportBBox.top,
mouseEvent.clientY + viewportBBox.top,
mouseEvent.clientX + viewportBBox.left,
mouseEvent.clientX + 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);
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');
browserEvents.conditionalBind(
menuDom as EventTarget,
'contextmenu',
null,
haltPropagation,
);
menu.focus();
}
* Halts the propagation of the event without doing anything else.
*
* @param e An event.
*/
function haltPropagation(e: Event) {
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;
}
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(eventUtils.BLOCK_CREATE))(newBlock));
}
common.setSelected(newBlock);
return newBlock;
};
}