Files
blockly/core/contextmenu.ts
Beka Westberg ed403d0b77 feat!: change gestures to look at selected when dragging (#7991)
* feat: change gestures to look at selected when dragging

* chore: fix tests

* chore: format

* chore: PR comments
2024-04-04 08:52:43 -07:00

391 lines
11 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 * as clipboard from './clipboard.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 {Msg} from './msg.js';
import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.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 {WorkspaceCommentSvg} from './workspace_comment_svg.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.
*/
export function show(
e: Event,
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
) {
WidgetDiv.show(dummyOwner, rtl, dispose);
if (!options.length) {
hide();
return;
}
const menu = populate_(options, rtl);
menu_ = menu;
position_(menu, e, rtl);
// 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.
* @returns The menu that will be shown on right click.
*/
function populate_(
options: (ContextMenuOption | LegacyContextMenuOption)[],
rtl: boolean,
): 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];
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(() => {
// 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);
}, 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) {
// Record windowSize and scrollOffset before adding menu.
const viewportBBox = svgMath.getViewportBBox();
const mouseEvent = e as MouseEvent;
// 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(
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);
// 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(eventUtils.BLOCK_CREATE))(newBlock));
}
common.setSelected(newBlock);
return newBlock;
};
}
// Helper functions for creating context menu options.
/**
* Make a context menu option for deleting the current workspace comment.
*
* @param comment The workspace comment where the
* right-click originated.
* @returns A menu option,
* containing text, enabled, and a callback.
* @internal
*/
export function commentDeleteOption(
comment: WorkspaceCommentSvg,
): LegacyContextMenuOption {
const deleteOption = {
text: Msg['REMOVE_COMMENT'],
enabled: true,
callback: function () {
eventUtils.setGroup(true);
comment.dispose();
eventUtils.setGroup(false);
},
};
return deleteOption;
}
/**
* Make a context menu option for duplicating the current workspace comment.
*
* @param comment The workspace comment where the
* right-click originated.
* @returns A menu option,
* containing text, enabled, and a callback.
* @internal
*/
export function commentDuplicateOption(
comment: WorkspaceCommentSvg,
): LegacyContextMenuOption {
const duplicateOption = {
text: Msg['DUPLICATE_COMMENT'],
enabled: true,
callback: function () {
const data = comment.toCopyData();
if (!data) return;
clipboard.paste(data, comment.workspace);
},
};
return duplicateOption;
}
/**
* Make a context menu option for adding a comment on the workspace.
*
* @param ws The workspace where the right-click
* originated.
* @param e The right-click mouse event.
* @returns A menu option, containing text, enabled, and a callback.
* comments are not bundled in.
* @internal
*/
export function workspaceCommentOption(
ws: WorkspaceSvg,
e: Event,
): ContextMenuOption {
/**
* Helper function to create and position a comment correctly based on the
* location of the mouse event.
*/
function addWsComment() {
const comment = new WorkspaceCommentSvg(
ws,
Msg['WORKSPACE_COMMENT_DEFAULT_TEXT'],
WorkspaceCommentSvg.DEFAULT_SIZE,
WorkspaceCommentSvg.DEFAULT_SIZE,
);
const injectionDiv = ws.getInjectionDiv();
// Bounding rect coordinates are in client coordinates, meaning that they
// are in pixels relative to the upper left corner of the visible browser
// window. These coordinates change when you scroll the browser window.
const boundingRect = injectionDiv.getBoundingClientRect();
// The client coordinates offset by the injection div's upper left corner.
const mouseEvent = e as MouseEvent;
const clientOffsetPixels = new Coordinate(
mouseEvent.clientX - boundingRect.left,
mouseEvent.clientY - boundingRect.top,
);
// The offset in pixels between the main workspace's origin and the upper
// left corner of the injection div.
const mainOffsetPixels = ws.getOriginOffsetInPixels();
// The position of the new comment in pixels relative to the origin of the
// main workspace.
const finalOffset = Coordinate.difference(
clientOffsetPixels,
mainOffsetPixels,
);
// The position of the new comment in main workspace coordinates.
finalOffset.scale(1 / ws.scale);
const commentX = finalOffset.x;
const commentY = finalOffset.y;
comment.moveBy(commentX, commentY);
if (ws.rendered) {
comment.initSvg();
comment.render();
common.setSelected(comment);
}
}
const wsCommentOption = {
enabled: true,
} as ContextMenuOption;
wsCommentOption.text = Msg['ADD_COMMENT'];
wsCommentOption.callback = function () {
addWsComment();
};
return wsCommentOption;
}