mirror of
https://github.com/google/blockly.git
synced 2025-12-16 14:20:10 +01:00
* 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.
296 lines
8.5 KiB
TypeScript
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;
|
|
};
|
|
}
|