/** * @license * Visual Blocks Editor * * Copyright 2011 Google Inc. * https://developers.google.com/blockly/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @fileoverview Functionality for the right-click context menus. * @author fraser@google.com (Neil Fraser) */ 'use strict'; /** * @name Blockly.ContextMenu * @namespace */ goog.provide('Blockly.ContextMenu'); goog.require('goog.dom'); goog.require('goog.events'); goog.require('goog.style'); goog.require('goog.ui.Menu'); goog.require('goog.ui.MenuItem'); /** * Which block is the context menu attached to? * @type {Blockly.Block} */ Blockly.ContextMenu.currentBlock = null; /** * @type {Array.} Opaque data that can be passed to unbindEvent_. * @private */ Blockly.ContextMenu.eventWrapper_ = null; /** * Construct the menu based on the list of options and show the menu. * @param {!Event} e Mouse event. * @param {!Array.} options Array of menu options. * @param {boolean} rtl True if RTL, false if LTR. */ Blockly.ContextMenu.show = function(e, options, rtl) { Blockly.WidgetDiv.show(Blockly.ContextMenu, rtl, null); if (!options.length) { Blockly.ContextMenu.hide(); return; } var menu = Blockly.ContextMenu.populate_(options, rtl); goog.events.listen(menu, goog.ui.Component.EventType.ACTION, Blockly.ContextMenu.hide); Blockly.ContextMenu.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.getElement().focus();}, 1); Blockly.ContextMenu.currentBlock = null; // May be set by Blockly.Block. }; /** * Create the context menu object and populate it with the given options. * @param {!Array.} options Array of menu options. * @param {boolean} rtl True if RTL, false if LTR. * @return {!goog.ui.Menu} The menu that will be shown on right click. * @private */ Blockly.ContextMenu.populate_ = function(options, rtl) { /* Here's what one option object looks like: {text: 'Make It So', enabled: true, callback: Blockly.MakeItSo} */ var menu = new goog.ui.Menu(); menu.setAllowAutoFocus(true); menu.setRightToLeft(rtl); for (var i = 0, option; option = options[i]; i++) { var menuItem = new goog.ui.MenuItem(option.text); menuItem.setRightToLeft(rtl); menu.addChild(menuItem, true); menuItem.setEnabled(option.enabled); if (option.enabled) { goog.events.listen(menuItem, goog.ui.Component.EventType.ACTION, option.callback); menuItem.handleContextMenu = function(/* e */) { // Right-clicking on menu option should count as a click. goog.events.dispatchEvent(this, goog.ui.Component.EventType.ACTION); }; } } return menu; }; /** * Add the menu to the page and position it correctly. * @param {!goog.ui.Menu} menu The menu to add and position. * @param {!Event} e Mouse event for the right click that is making the context * menu appear. * @param {boolean} rtl True if RTL, false if LTR. * @private */ Blockly.ContextMenu.position_ = function(menu, e, rtl) { // Record windowSize and scrollOffset before adding menu. var windowSize = goog.dom.getViewportSize(); var scrollOffset = goog.style.getViewportPageOffset(document); var div = Blockly.WidgetDiv.DIV; menu.render(div); var menuDom = menu.getElement(); Blockly.utils.addClass(menuDom, 'blocklyContextMenu'); // Prevent system context menu when right-clicking a Blockly context menu. Blockly.bindEventWithChecks_(menuDom, 'contextmenu', null, Blockly.utils.noEvent); // Record menuSize after adding menu. var menuSize = goog.style.getSize(menuDom); // Position the menu. var x = e.clientX + scrollOffset.x; var y = e.clientY + scrollOffset.y; // Flip menu vertically if off the bottom. if (e.clientY + menuSize.height >= windowSize.height) { y -= menuSize.height; } // Flip menu horizontally if off the edge. if (rtl) { if (menuSize.width >= e.clientX) { x += menuSize.width; } } else { if (e.clientX + menuSize.width >= windowSize.width) { x -= menuSize.width; } } Blockly.WidgetDiv.position(x, y, windowSize, scrollOffset, rtl); }; /** * Hide the context menu. */ Blockly.ContextMenu.hide = function() { Blockly.WidgetDiv.hideIfOwner(Blockly.ContextMenu); Blockly.ContextMenu.currentBlock = null; if (Blockly.ContextMenu.eventWrapper_) { Blockly.unbindEvent_(Blockly.ContextMenu.eventWrapper_); } }; /** * Create a callback function that creates and configures a block, * then places the new block next to the original. * @param {!Blockly.Block} block Original block. * @param {!Element} xml XML representation of new block. * @return {!Function} Function that creates a block. */ Blockly.ContextMenu.callbackFactory = function(block, xml) { return function() { Blockly.Events.disable(); try { var newBlock = Blockly.Xml.domToBlock(xml, block.workspace); // Move the new block next to the old block. var xy = block.getRelativeToSurfaceXY(); if (block.RTL) { xy.x -= Blockly.SNAP_RADIUS; } else { xy.x += Blockly.SNAP_RADIUS; } xy.y += Blockly.SNAP_RADIUS * 2; newBlock.moveBy(xy.x, xy.y); } finally { Blockly.Events.enable(); } if (Blockly.Events.isEnabled() && !newBlock.isShadow()) { Blockly.Events.fire(new Blockly.Events.BlockCreate(newBlock)); } newBlock.select(); }; }; // Helper functions for creating context menu options. /** * Make a context menu option for deleting the current block. * @param {!Blockly.BlockSvg} block The block where the right-click originated. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.blockDeleteOption = function(block) { // Option to delete this block but not blocks lower in the stack. // Count the number of blocks that are nested in this block. var descendantCount = block.getDescendants(true).length; var nextBlock = block.getNextBlock(); if (nextBlock) { // Blocks in the current stack would survive this block's deletion. descendantCount -= nextBlock.getDescendants(true).length; } var deleteOption = { text: descendantCount == 1 ? Blockly.Msg.DELETE_BLOCK : Blockly.Msg.DELETE_X_BLOCKS.replace('%1', String(descendantCount)), enabled: true, callback: function() { Blockly.Events.setGroup(true); block.dispose(true, true); Blockly.Events.setGroup(false); } }; return deleteOption; }; /** * Make a context menu option for showing help for the current block. * @param {!Blockly.BlockSvg} block The block where the right-click originated. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.blockHelpOption = function(block) { var url = goog.isFunction(block.helpUrl) ? block.helpUrl() : block.helpUrl; var helpOption = { enabled: !!url, text: Blockly.Msg.HELP, callback: function() { block.showHelp_(); } }; return helpOption; }; /** * Make a context menu option for duplicating the current block. * @param {!Blockly.BlockSvg} block The block where the right-click originated. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.blockDuplicateOption = function(block) { var duplicateOption = { text: Blockly.Msg.DUPLICATE_BLOCK, enabled: true, callback: block.duplicateAndDragCallback_() }; return duplicateOption; }; /** * Make a context menu option for adding or removing comments on the current * block. * @param {!Blockly.BlockSvg} block The block where the right-click originated. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.blockCommentOption = function(block) { var commentOption = { enabled: !goog.userAgent.IE }; // If there's already a comment, add an option to delete it. if (block.comment) { commentOption.text = Blockly.Msg.REMOVE_COMMENT; commentOption.callback = function() { block.setCommentText(null); }; } else { // If there's no comment, add an option to create a comment. commentOption.text = Blockly.Msg.ADD_COMMENT; commentOption.callback = function() { block.setCommentText(''); }; } return commentOption; }; /** * Make a context menu option for undoing the most recent action on the * workspace. * @param {!Blockly.WorkspaceSvg} ws The workspace where the right-click * originated. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.wsUndoOption = function(ws) { return { text: Blockly.Msg.UNDO, enabled: ws.hasUndoStack(), callback: ws.undo.bind(ws, false) }; }; /** * Make a context menu option for redoing the most recent action on the * workspace. * @param {!Blockly.WorkspaceSvg} ws The workspace where the right-click * originated. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.wsRedoOption = function(ws) { return { text: Blockly.Msg.REDO, enabled: ws.hasRedoStack(), callback: ws.undo.bind(ws, true) }; }; /** * Make a context menu option for cleaning up blocks on the workspace, by * aligning them vertically. * @param {!Blockly.WorkspaceSvg} ws The workspace where the right-click * originated. * @param {number} numTopBlocks The number of top blocks on the workspace. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.wsCleanupOption = function(ws, numTopBlocks) { return { text: Blockly.Msg.CLEAN_UP, enabled: numTopBlocks > 1, callback: ws.cleanUp.bind(ws, true) }; }; /** * Helper function for toggling delete state on blocks on the workspace, to be * called from a right-click menu. * @param {!Array.} topBlocks The list of top blocks on the * the workspace. * @param {boolean} shouldCollapse True if the blocks should be collapsed, false * if they should be expanded. * @private */ Blockly.ContextMenu.toggleCollapseFn_ = function(topBlocks, shouldCollapse) { // Add a little animation to collapsing and expanding. var DELAY = 10; var ms = 0; for (var i = 0; i < topBlocks.length; i++) { var block = topBlocks[i]; while (block) { setTimeout(block.setCollapsed.bind(block, shouldCollapse), ms); block = block.getNextBlock(); ms += DELAY; } } }; /** * Make a context menu option for collapsing all block stacks on the workspace. * @param {boolean} hasExpandedBlocks Whether there are any non-collapsed blocks * on the workspace. * @param {!Array.} topBlocks The list of top blocks on the * the workspace. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.wsCollapseOption = function(hasExpandedBlocks, topBlocks) { return { enabled: hasExpandedBlocks, text: Blockly.Msg.COLLAPSE_ALL, callback: function() { Blockly.ContextMenu.toggleCollapseFn_(topBlocks, true); } }; }; /** * Make a context menu option for expanding all block stacks on the workspace. * @param {boolean} hasCollapsedBlocks Whether there are any collapsed blocks * on the workspace. * @param {!Array.} topBlocks The list of top blocks on the * the workspace. * @return {!Object} A menu option, containing text, enabled, and a callback. * @package */ Blockly.ContextMenu.wsExpandOption = function(hasCollapsedBlocks, topBlocks) { return { enabled: hasCollapsedBlocks, text: Blockly.Msg.EXPAND_ALL, callback: function() { Blockly.ContextMenu.toggleCollapseFn_(topBlocks, false); } }; }; // End helper functions for creating context menu options.