diff options
Diffstat (limited to 'src/blockly/core/flyout.js')
-rw-r--r-- | src/blockly/core/flyout.js | 1364 |
1 files changed, 1364 insertions, 0 deletions
diff --git a/src/blockly/core/flyout.js b/src/blockly/core/flyout.js new file mode 100644 index 0000000..03ea150 --- /dev/null +++ b/src/blockly/core/flyout.js @@ -0,0 +1,1364 @@ +/** + * @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 Flyout tray containing blocks which may be created. + * @author fraser@google.com (Neil Fraser) + */ +'use strict'; + +goog.provide('Blockly.Flyout'); + +goog.require('Blockly.Block'); +goog.require('Blockly.Comment'); +goog.require('Blockly.Events'); +goog.require('Blockly.FlyoutButton'); +goog.require('Blockly.WorkspaceSvg'); +goog.require('goog.dom'); +goog.require('goog.events'); +goog.require('goog.math.Rect'); +goog.require('goog.userAgent'); + + +/** + * Class for a flyout. + * @param {!Object} workspaceOptions Dictionary of options for the workspace. + * @constructor + */ +Blockly.Flyout = function(workspaceOptions) { + workspaceOptions.getMetrics = this.getMetrics_.bind(this); + workspaceOptions.setMetrics = this.setMetrics_.bind(this); + /** + * @type {!Blockly.Workspace} + * @private + */ + this.workspace_ = new Blockly.WorkspaceSvg(workspaceOptions); + this.workspace_.isFlyout = true; + + /** + * Is RTL vs LTR. + * @type {boolean} + */ + this.RTL = !!workspaceOptions.RTL; + + /** + * Flyout should be laid out horizontally vs vertically. + * @type {boolean} + * @private + */ + this.horizontalLayout_ = workspaceOptions.horizontalLayout; + + /** + * Position of the toolbox and flyout relative to the workspace. + * @type {number} + * @private + */ + this.toolboxPosition_ = workspaceOptions.toolboxPosition; + + /** + * Opaque data that can be passed to Blockly.unbindEvent_. + * @type {!Array.<!Array>} + * @private + */ + this.eventWrappers_ = []; + + /** + * List of background buttons that lurk behind each block to catch clicks + * landing in the blocks' lakes and bays. + * @type {!Array.<!Element>} + * @private + */ + this.backgroundButtons_ = []; + + /** + * List of visible buttons. + * @type {!Array.<!Blockly.FlyoutButton>} + * @private + */ + this.buttons_ = []; + + /** + * List of event listeners. + * @type {!Array.<!Array>} + * @private + */ + this.listeners_ = []; + + /** + * List of blocks that should always be disabled. + * @type {!Array.<!Blockly.Block>} + * @private + */ + this.permanentlyDisabled_ = []; + + /** + * y coordinate of mousedown - used to calculate scroll distances. + * @private {number} + */ + this.startDragMouseY_ = 0; + + /** + * x coordinate of mousedown - used to calculate scroll distances. + * @private {number} + */ + this.startDragMouseX_ = 0; +}; + +/** + * When a flyout drag is in progress, this is a reference to the flyout being + * dragged. This is used by Flyout.terminateDrag_ to reset dragMode_. + * @private {Blockly.Flyout} + */ +Blockly.Flyout.startFlyout_ = null; + +/** + * Event that started a drag. Used to determine the drag distance/direction and + * also passed to BlockSvg.onMouseDown_() after creating a new block. + * @private {Event} + */ +Blockly.Flyout.startDownEvent_ = null; + +/** + * Flyout block where the drag/click was initiated. Used to fire click events or + * create a new block. + * @private {Event} + */ +Blockly.Flyout.startBlock_ = null; + +/** + * Wrapper function called when a mouseup occurs during a background or block + * drag operation. + * @private {Array.<!Array>} + */ +Blockly.Flyout.onMouseUpWrapper_ = null; + +/** + * Wrapper function called when a mousemove occurs during a background drag. + * @private {Array.<!Array>} + */ +Blockly.Flyout.onMouseMoveWrapper_ = null; + +/** + * Wrapper function called when a mousemove occurs during a block drag. + * @private {Array.<!Array>} + */ +Blockly.Flyout.onMouseMoveBlockWrapper_ = null; + +/** + * Does the flyout automatically close when a block is created? + * @type {boolean} + */ +Blockly.Flyout.prototype.autoClose = true; + +/** + * Corner radius of the flyout background. + * @type {number} + * @const + */ +Blockly.Flyout.prototype.CORNER_RADIUS = 8; + +/** + * Number of pixels the mouse must move before a drag/scroll starts. Because the + * drag-intention is determined when this is reached, it is larger than + * Blockly.DRAG_RADIUS so that the drag-direction is clearer. + */ +Blockly.Flyout.prototype.DRAG_RADIUS = 10; + +/** + * Margin around the edges of the blocks in the flyout. + * @type {number} + * @const + */ +Blockly.Flyout.prototype.MARGIN = Blockly.Flyout.prototype.CORNER_RADIUS; + +/** + * Gap between items in horizontal flyouts. Can be overridden with the "sep" + * element. + * @const {number} + */ +Blockly.Flyout.prototype.GAP_X = Blockly.Flyout.prototype.MARGIN * 3; + +/** + * Gap between items in vertical flyouts. Can be overridden with the "sep" + * element. + * @const {number} + */ +Blockly.Flyout.prototype.GAP_Y = Blockly.Flyout.prototype.MARGIN * 3; + +/** + * Top/bottom padding between scrollbar and edge of flyout background. + * @type {number} + * @const + */ +Blockly.Flyout.prototype.SCROLLBAR_PADDING = 2; + +/** + * Width of flyout. + * @type {number} + * @private + */ +Blockly.Flyout.prototype.width_ = 0; + +/** + * Height of flyout. + * @type {number} + * @private + */ +Blockly.Flyout.prototype.height_ = 0; + +/** + * Is the flyout dragging (scrolling)? + * DRAG_NONE - no drag is ongoing or state is undetermined. + * DRAG_STICKY - still within the sticky drag radius. + * DRAG_FREE - in scroll mode (never create a new block). + * @private + */ +Blockly.Flyout.prototype.dragMode_ = Blockly.DRAG_NONE; + +/** + * Range of a drag angle from a flyout considered "dragging toward workspace". + * Drags that are within the bounds of this many degrees from the orthogonal + * line to the flyout edge are considered to be "drags toward the workspace". + * Example: + * Flyout Edge Workspace + * [block] / <-within this angle, drags "toward workspace" | + * [block] ---- orthogonal to flyout boundary ---- | + * [block] \ | + * The angle is given in degrees from the orthogonal. + * + * This is used to know when to create a new block and when to scroll the + * flyout. Setting it to 360 means that all drags create a new block. + * @type {number} + * @private +*/ +Blockly.Flyout.prototype.dragAngleRange_ = 70; + +/** + * Creates the flyout's DOM. Only needs to be called once. + * @return {!Element} The flyout's SVG group. + */ +Blockly.Flyout.prototype.createDom = function() { + /* + <g> + <path class="blocklyFlyoutBackground"/> + <g class="blocklyFlyout"></g> + </g> + */ + this.svgGroup_ = Blockly.createSvgElement('g', + {'class': 'blocklyFlyout'}, null); + this.svgBackground_ = Blockly.createSvgElement('path', + {'class': 'blocklyFlyoutBackground'}, this.svgGroup_); + this.svgGroup_.appendChild(this.workspace_.createDom()); + return this.svgGroup_; +}; + +/** + * Initializes the flyout. + * @param {!Blockly.Workspace} targetWorkspace The workspace in which to create + * new blocks. + */ +Blockly.Flyout.prototype.init = function(targetWorkspace) { + this.targetWorkspace_ = targetWorkspace; + this.workspace_.targetWorkspace = targetWorkspace; + // Add scrollbar. + this.scrollbar_ = new Blockly.Scrollbar(this.workspace_, + this.horizontalLayout_, false); + + this.hide(); + + Array.prototype.push.apply(this.eventWrappers_, + Blockly.bindEvent_(this.svgGroup_, 'wheel', this, this.wheel_)); + if (!this.autoClose) { + this.filterWrapper_ = this.filterForCapacity_.bind(this); + this.targetWorkspace_.addChangeListener(this.filterWrapper_); + } + // Dragging the flyout up and down. + Array.prototype.push.apply(this.eventWrappers_, + Blockly.bindEvent_(this.svgGroup_, 'mousedown', this, this.onMouseDown_)); +}; + +/** + * Dispose of this flyout. + * Unlink from all DOM elements to prevent memory leaks. + */ +Blockly.Flyout.prototype.dispose = function() { + this.hide(); + Blockly.unbindEvent_(this.eventWrappers_); + if (this.filterWrapper_) { + this.targetWorkspace_.removeChangeListener(this.filterWrapper_); + this.filterWrapper_ = null; + } + if (this.scrollbar_) { + this.scrollbar_.dispose(); + this.scrollbar_ = null; + } + if (this.workspace_) { + this.workspace_.targetWorkspace = null; + this.workspace_.dispose(); + this.workspace_ = null; + } + if (this.svgGroup_) { + goog.dom.removeNode(this.svgGroup_); + this.svgGroup_ = null; + } + this.svgBackground_ = null; + this.targetWorkspace_ = null; +}; + +/** + * Get the width of the flyout. + * @return {number} The width of the flyout. + */ +Blockly.Flyout.prototype.getWidth = function() { + return this.width_; +}; + +/** + * Get the height of the flyout. + * @return {number} The width of the flyout. + */ +Blockly.Flyout.prototype.getHeight = function() { + return this.height_; +}; + +/** + * Return an object with all the metrics required to size scrollbars for the + * flyout. The following properties are computed: + * .viewHeight: Height of the visible rectangle, + * .viewWidth: Width of the visible rectangle, + * .contentHeight: Height of the contents, + * .contentWidth: Width of the contents, + * .viewTop: Offset of top edge of visible rectangle from parent, + * .contentTop: Offset of the top-most content from the y=0 coordinate, + * .absoluteTop: Top-edge of view. + * .viewLeft: Offset of the left edge of visible rectangle from parent, + * .contentLeft: Offset of the left-most content from the x=0 coordinate, + * .absoluteLeft: Left-edge of view. + * @return {Object} Contains size and position metrics of the flyout. + * @private + */ +Blockly.Flyout.prototype.getMetrics_ = function() { + if (!this.isVisible()) { + // Flyout is hidden. + return null; + } + + try { + var optionBox = this.workspace_.getCanvas().getBBox(); + } catch (e) { + // Firefox has trouble with hidden elements (Bug 528969). + var optionBox = {height: 0, y: 0, width: 0, x: 0}; + } + + var absoluteTop = this.SCROLLBAR_PADDING; + var absoluteLeft = this.SCROLLBAR_PADDING; + if (this.horizontalLayout_) { + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_BOTTOM) { + absoluteTop = 0; + } + var viewHeight = this.height_; + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_TOP) { + viewHeight += this.MARGIN - this.SCROLLBAR_PADDING; + } + var viewWidth = this.width_ - 2 * this.SCROLLBAR_PADDING; + } else { + absoluteLeft = 0; + var viewHeight = this.height_ - 2 * this.SCROLLBAR_PADDING; + var viewWidth = this.width_; + if (!this.RTL) { + viewWidth -= this.SCROLLBAR_PADDING; + } + } + + var metrics = { + viewHeight: viewHeight, + viewWidth: viewWidth, + contentHeight: (optionBox.height + 2 * this.MARGIN) * this.workspace_.scale, + contentWidth: (optionBox.width + 2 * this.MARGIN) * this.workspace_.scale, + viewTop: -this.workspace_.scrollY, + viewLeft: -this.workspace_.scrollX, + contentTop: optionBox.y, + contentLeft: optionBox.x, + absoluteTop: absoluteTop, + absoluteLeft: absoluteLeft + }; + return metrics; +}; + +/** + * Sets the translation of the flyout to match the scrollbars. + * @param {!Object} xyRatio Contains a y property which is a float + * between 0 and 1 specifying the degree of scrolling and a + * similar x property. + * @private + */ +Blockly.Flyout.prototype.setMetrics_ = function(xyRatio) { + var metrics = this.getMetrics_(); + // This is a fix to an apparent race condition. + if (!metrics) { + return; + } + if (!this.horizontalLayout_ && goog.isNumber(xyRatio.y)) { + this.workspace_.scrollY = -metrics.contentHeight * xyRatio.y; + } else if (this.horizontalLayout_ && goog.isNumber(xyRatio.x)) { + this.workspace_.scrollX = -metrics.contentWidth * xyRatio.x; + } + + this.workspace_.translate(this.workspace_.scrollX + metrics.absoluteLeft, + this.workspace_.scrollY + metrics.absoluteTop); +}; + +/** + * Move the flyout to the edge of the workspace. + */ +Blockly.Flyout.prototype.position = function() { + if (!this.isVisible()) { + return; + } + var targetWorkspaceMetrics = this.targetWorkspace_.getMetrics(); + if (!targetWorkspaceMetrics) { + // Hidden components will return null. + return; + } + var edgeWidth = this.horizontalLayout_ ? + targetWorkspaceMetrics.viewWidth : this.width_; + edgeWidth -= this.CORNER_RADIUS; + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_RIGHT) { + edgeWidth *= -1; + } + + this.setBackgroundPath_(edgeWidth, + this.horizontalLayout_ ? this.height_ : + targetWorkspaceMetrics.viewHeight); + + var x = targetWorkspaceMetrics.absoluteLeft; + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_RIGHT) { + x += targetWorkspaceMetrics.viewWidth; + x -= this.width_; + } + + var y = targetWorkspaceMetrics.absoluteTop; + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_BOTTOM) { + y += targetWorkspaceMetrics.viewHeight; + y -= this.height_; + } + + this.svgGroup_.setAttribute('transform', 'translate(' + x + ',' + y + ')'); + + // Record the height for Blockly.Flyout.getMetrics_, or width if the layout is + // horizontal. + if (this.horizontalLayout_) { + this.width_ = targetWorkspaceMetrics.viewWidth; + } else { + this.height_ = targetWorkspaceMetrics.viewHeight; + } + + // Update the scrollbar (if one exists). + if (this.scrollbar_) { + this.scrollbar_.resize(); + } +}; + +/** + * Create and set the path for the visible boundaries of the flyout. + * @param {number} width The width of the flyout, not including the + * rounded corners. + * @param {number} height The height of the flyout, not including + * rounded corners. + * @private + */ +Blockly.Flyout.prototype.setBackgroundPath_ = function(width, height) { + if (this.horizontalLayout_) { + this.setBackgroundPathHorizontal_(width, height); + } else { + this.setBackgroundPathVertical_(width, height); + } +}; + +/** + * Create and set the path for the visible boundaries of the flyout in vertical + * mode. + * @param {number} width The width of the flyout, not including the + * rounded corners. + * @param {number} height The height of the flyout, not including + * rounded corners. + * @private + */ +Blockly.Flyout.prototype.setBackgroundPathVertical_ = function(width, height) { + var atRight = this.toolboxPosition_ == Blockly.TOOLBOX_AT_RIGHT; + // Decide whether to start on the left or right. + var path = ['M ' + (atRight ? this.width_ : 0) + ',0']; + // Top. + path.push('h', width); + // Rounded corner. + path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, + atRight ? 0 : 1, + atRight ? -this.CORNER_RADIUS : this.CORNER_RADIUS, + this.CORNER_RADIUS); + // Side closest to workspace. + path.push('v', Math.max(0, height - this.CORNER_RADIUS * 2)); + // Rounded corner. + path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, + atRight ? 0 : 1, + atRight ? this.CORNER_RADIUS : -this.CORNER_RADIUS, + this.CORNER_RADIUS); + // Bottom. + path.push('h', -width); + path.push('z'); + this.svgBackground_.setAttribute('d', path.join(' ')); +}; + +/** + * Create and set the path for the visible boundaries of the flyout in + * horizontal mode. + * @param {number} width The width of the flyout, not including the + * rounded corners. + * @param {number} height The height of the flyout, not including + * rounded corners. + * @private + */ +Blockly.Flyout.prototype.setBackgroundPathHorizontal_ = function(width, + height) { + var atTop = this.toolboxPosition_ == Blockly.TOOLBOX_AT_TOP; + // Start at top left. + var path = ['M 0,' + (atTop ? 0 : this.CORNER_RADIUS)]; + + if (atTop) { + // Top. + path.push('h', width + this.CORNER_RADIUS); + // Right. + path.push('v', height); + // Bottom. + path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, + -this.CORNER_RADIUS, this.CORNER_RADIUS); + path.push('h', -1 * (width - this.CORNER_RADIUS)); + // Left. + path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, + -this.CORNER_RADIUS, -this.CORNER_RADIUS); + path.push('z'); + } else { + // Top. + path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, + this.CORNER_RADIUS, -this.CORNER_RADIUS); + path.push('h', width - this.CORNER_RADIUS); + // Right. + path.push('a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, + this.CORNER_RADIUS, this.CORNER_RADIUS); + path.push('v', height - this.CORNER_RADIUS); + // Bottom. + path.push('h', -width - this.CORNER_RADIUS); + // Left. + path.push('z'); + } + this.svgBackground_.setAttribute('d', path.join(' ')); +}; + +/** + * Scroll the flyout to the top. + */ +Blockly.Flyout.prototype.scrollToStart = function() { + this.scrollbar_.set((this.horizontalLayout_ && this.RTL) ? Infinity : 0); +}; + +/** + * Scroll the flyout. + * @param {!Event} e Mouse wheel scroll event. + * @private + */ +Blockly.Flyout.prototype.wheel_ = function(e) { + var delta = this.horizontalLayout_ ? e.deltaX : e.deltaY; + + if (delta) { + if (goog.userAgent.GECKO) { + // Firefox's deltas are a tenth that of Chrome/Safari. + delta *= 10; + } + var metrics = this.getMetrics_(); + var pos = this.horizontalLayout_ ? metrics.viewLeft + delta : + metrics.viewTop + delta; + var limit = this.horizontalLayout_ ? + metrics.contentWidth - metrics.viewWidth : + metrics.contentHeight - metrics.viewHeight; + pos = Math.min(pos, limit); + pos = Math.max(pos, 0); + this.scrollbar_.set(pos); + } + + // Don't scroll the page. + e.preventDefault(); + // Don't propagate mousewheel event (zooming). + e.stopPropagation(); +}; + +/** + * Is the flyout visible? + * @return {boolean} True if visible. + */ +Blockly.Flyout.prototype.isVisible = function() { + return this.svgGroup_ && this.svgGroup_.style.display == 'block'; +}; + +/** + * Hide and empty the flyout. + */ +Blockly.Flyout.prototype.hide = function() { + if (!this.isVisible()) { + return; + } + this.svgGroup_.style.display = 'none'; + // Delete all the event listeners. + for (var x = 0, listen; listen = this.listeners_[x]; x++) { + Blockly.unbindEvent_(listen); + } + this.listeners_.length = 0; + if (this.reflowWrapper_) { + this.workspace_.removeChangeListener(this.reflowWrapper_); + this.reflowWrapper_ = null; + } + // Do NOT delete the blocks here. Wait until Flyout.show. + // https://neil.fraser.name/news/2014/08/09/ +}; + +/** + * Show and populate the flyout. + * @param {!Array|string} xmlList List of blocks to show. + * Variables and procedures have a custom set of blocks. + */ +Blockly.Flyout.prototype.show = function(xmlList) { + this.hide(); + this.clearOldBlocks_(); + + if (xmlList == Blockly.Variables.NAME_TYPE) { + // Special category for variables. + xmlList = + Blockly.Variables.flyoutCategory(this.workspace_.targetWorkspace); + } else if (xmlList == Blockly.Procedures.NAME_TYPE) { + // Special category for procedures. + xmlList = + Blockly.Procedures.flyoutCategory(this.workspace_.targetWorkspace); + } + + this.svgGroup_.style.display = 'block'; + // Create the blocks to be shown in this flyout. + var contents = []; + var gaps = []; + this.permanentlyDisabled_.length = 0; + for (var i = 0, xml; xml = xmlList[i]; i++) { + if (xml.tagName) { + var tagName = xml.tagName.toUpperCase(); + var default_gap = this.horizontalLayout_ ? this.GAP_X : this.GAP_Y; + if (tagName == 'BLOCK') { + var curBlock = Blockly.Xml.domToBlock(xml, this.workspace_); + if (curBlock.disabled) { + // Record blocks that were initially disabled. + // Do not enable these blocks as a result of capacity filtering. + this.permanentlyDisabled_.push(curBlock); + } + contents.push({type: 'block', block: curBlock}); + var gap = parseInt(xml.getAttribute('gap'), 10); + gaps.push(isNaN(gap) ? default_gap : gap); + } else if (xml.tagName.toUpperCase() == 'SEP') { + // Change the gap between two blocks. + // <sep gap="36"></sep> + // The default gap is 24, can be set larger or smaller. + // This overwrites the gap attribute on the previous block. + // Note that a deprecated method is to add a gap to a block. + // <block type="math_arithmetic" gap="8"></block> + var newGap = parseInt(xml.getAttribute('gap'), 10); + // Ignore gaps before the first block. + if (!isNaN(newGap) && gaps.length > 0) { + gaps[gaps.length - 1] = newGap; + } else { + gaps.push(default_gap); + } + } else if (tagName == 'BUTTON') { + var label = xml.getAttribute('text'); + var curButton = new Blockly.FlyoutButton(this.workspace_, + this.targetWorkspace_, label); + contents.push({type: 'button', button: curButton}); + gaps.push(default_gap); + } + } + } + + this.layout_(contents, gaps); + + // IE 11 is an incompetent browser that fails to fire mouseout events. + // When the mouse is over the background, deselect all blocks. + var deselectAll = function() { + var topBlocks = this.workspace_.getTopBlocks(false); + for (var i = 0, block; block = topBlocks[i]; i++) { + block.removeSelect(); + } + }; + + this.listeners_.push(Blockly.bindEvent_(this.svgBackground_, 'mouseover', + this, deselectAll)); + + if (this.horizontalLayout_) { + this.height_ = 0; + } else { + this.width_ = 0; + } + this.reflow(); + + this.filterForCapacity_(); + + // Correctly position the flyout's scrollbar when it opens. + this.position(); + + this.reflowWrapper_ = this.reflow.bind(this); + this.workspace_.addChangeListener(this.reflowWrapper_); +}; + +/** + * Lay out the blocks in the flyout. + * @param {!Array.<!Object>} contents The blocks and buttons to lay out. + * @param {!Array.<number>} gaps The visible gaps between blocks. + * @private + */ +Blockly.Flyout.prototype.layout_ = function(contents, gaps) { + this.workspace_.scale = this.targetWorkspace_.scale; + var margin = this.MARGIN; + var cursorX = this.RTL ? margin : margin + Blockly.BlockSvg.TAB_WIDTH; + var cursorY = margin; + if (this.horizontalLayout_ && this.RTL) { + contents = contents.reverse(); + } + + for (var i = 0, item; item = contents[i]; i++) { + if (item.type == 'block') { + var block = item.block; + var allBlocks = block.getDescendants(); + for (var j = 0, child; child = allBlocks[j]; j++) { + // Mark blocks as being inside a flyout. This is used to detect and + // prevent the closure of the flyout if the user right-clicks on such a + // block. + child.isInFlyout = true; + } + block.render(); + var root = block.getSvgRoot(); + var blockHW = block.getHeightWidth(); + var tab = block.outputConnection ? Blockly.BlockSvg.TAB_WIDTH : 0; + if (this.horizontalLayout_) { + cursorX += tab; + } + block.moveBy((this.horizontalLayout_ && this.RTL) ? + cursorX + blockHW.width - tab : cursorX, + cursorY); + if (this.horizontalLayout_) { + cursorX += (blockHW.width + gaps[i] - tab); + } else { + cursorY += blockHW.height + gaps[i]; + } + + // Create an invisible rectangle under the block to act as a button. Just + // using the block as a button is poor, since blocks have holes in them. + var rect = Blockly.createSvgElement('rect', {'fill-opacity': 0}, null); + rect.tooltip = block; + Blockly.Tooltip.bindMouseEvents(rect); + // Add the rectangles under the blocks, so that the blocks' tooltips work. + this.workspace_.getCanvas().insertBefore(rect, block.getSvgRoot()); + block.flyoutRect_ = rect; + this.backgroundButtons_[i] = rect; + + this.addBlockListeners_(root, block, rect); + } else if (item.type == 'button') { + var button = item.button; + var buttonSvg = button.createDom(); + button.moveTo(cursorX, cursorY); + button.show(); + Blockly.bindEvent_(buttonSvg, 'mouseup', button, button.onMouseUp); + + this.buttons_.push(button); + if (this.horizontalLayout_) { + cursorX += (button.width + gaps[i]); + } else { + cursorY += button.height + gaps[i]; + } + } + } +}; + +/** + * Delete blocks and background buttons from a previous showing of the flyout. + * @private + */ +Blockly.Flyout.prototype.clearOldBlocks_ = function() { + // Delete any blocks from a previous showing. + var oldBlocks = this.workspace_.getTopBlocks(false); + for (var i = 0, block; block = oldBlocks[i]; i++) { + if (block.workspace == this.workspace_) { + block.dispose(false, false); + } + } + // Delete any background buttons from a previous showing. + for (var j = 0, rect; rect = this.backgroundButtons_[j]; j++) { + goog.dom.removeNode(rect); + } + this.backgroundButtons_.length = 0; + + for (var i = 0, button; button = this.buttons_[i]; i++) { + button.dispose(); + } + this.buttons_.length = 0; +}; + +/** + * Add listeners to a block that has been added to the flyout. + * @param {!Element} root The root node of the SVG group the block is in. + * @param {!Blockly.Block} block The block to add listeners for. + * @param {!Element} rect The invisible rectangle under the block that acts as + * a button for that block. + * @private + */ +Blockly.Flyout.prototype.addBlockListeners_ = function(root, block, rect) { + this.listeners_.push(Blockly.bindEvent_(root, 'mousedown', null, + this.blockMouseDown_(block))); + this.listeners_.push(Blockly.bindEvent_(rect, 'mousedown', null, + this.blockMouseDown_(block))); + this.listeners_.push(Blockly.bindEvent_(root, 'mouseover', block, + block.addSelect)); + this.listeners_.push(Blockly.bindEvent_(root, 'mouseout', block, + block.removeSelect)); + this.listeners_.push(Blockly.bindEvent_(rect, 'mouseover', block, + block.addSelect)); + this.listeners_.push(Blockly.bindEvent_(rect, 'mouseout', block, + block.removeSelect)); +}; + +/** + * Handle a mouse-down on an SVG block in a non-closing flyout. + * @param {!Blockly.Block} block The flyout block to copy. + * @return {!Function} Function to call when block is clicked. + * @private + */ +Blockly.Flyout.prototype.blockMouseDown_ = function(block) { + var flyout = this; + return function(e) { + Blockly.terminateDrag_(); + Blockly.hideChaff(true); + if (Blockly.isRightButton(e)) { + // Right-click. + block.showContextMenu_(e); + } else { + // Left-click (or middle click) + Blockly.Css.setCursor(Blockly.Css.Cursor.CLOSED); + // Record the current mouse position. + flyout.startDragMouseY_ = e.clientY; + flyout.startDragMouseX_ = e.clientX; + Blockly.Flyout.startDownEvent_ = e; + Blockly.Flyout.startBlock_ = block; + Blockly.Flyout.startFlyout_ = flyout; + Blockly.Flyout.onMouseUpWrapper_ = Blockly.bindEvent_(document, + 'mouseup', flyout, flyout.onMouseUp_); + Blockly.Flyout.onMouseMoveBlockWrapper_ = Blockly.bindEvent_(document, + 'mousemove', flyout, flyout.onMouseMoveBlock_); + } + // This event has been handled. No need to bubble up to the document. + e.stopPropagation(); + e.preventDefault(); + }; +}; + +/** + * Mouse down on the flyout background. Start a vertical scroll drag. + * @param {!Event} e Mouse down event. + * @private + */ +Blockly.Flyout.prototype.onMouseDown_ = function(e) { + if (Blockly.isRightButton(e)) { + return; + } + Blockly.hideChaff(true); + this.dragMode_ = Blockly.DRAG_FREE; + this.startDragMouseY_ = e.clientY; + this.startDragMouseX_ = e.clientX; + Blockly.Flyout.startFlyout_ = this; + Blockly.Flyout.onMouseMoveWrapper_ = Blockly.bindEvent_(document, 'mousemove', + this, this.onMouseMove_); + Blockly.Flyout.onMouseUpWrapper_ = Blockly.bindEvent_(document, 'mouseup', + this, Blockly.Flyout.terminateDrag_); + // This event has been handled. No need to bubble up to the document. + e.preventDefault(); + e.stopPropagation(); +}; + +/** + * Handle a mouse-up anywhere in the SVG pane. Is only registered when a + * block is clicked. We can't use mouseUp on the block since a fast-moving + * cursor can briefly escape the block before it catches up. + * @param {!Event} e Mouse up event. + * @private + */ +Blockly.Flyout.prototype.onMouseUp_ = function(e) { + if (!this.workspace_.isDragging()) { + if (this.autoClose) { + this.createBlockFunc_(Blockly.Flyout.startBlock_)( + Blockly.Flyout.startDownEvent_); + } else if (!Blockly.WidgetDiv.isVisible()) { + Blockly.Events.fire( + new Blockly.Events.Ui(Blockly.Flyout.startBlock_, 'click', + undefined, undefined)); + } + } + Blockly.terminateDrag_(); +}; + +/** + * Handle a mouse-move to vertically drag the flyout. + * @param {!Event} e Mouse move event. + * @private + */ +Blockly.Flyout.prototype.onMouseMove_ = function(e) { + var metrics = this.getMetrics_(); + if (this.horizontalLayout_) { + if (metrics.contentWidth - metrics.viewWidth < 0) { + return; + } + var dx = e.clientX - this.startDragMouseX_; + this.startDragMouseX_ = e.clientX; + var x = metrics.viewLeft - dx; + x = goog.math.clamp(x, 0, metrics.contentWidth - metrics.viewWidth); + this.scrollbar_.set(x); + } else { + if (metrics.contentHeight - metrics.viewHeight < 0) { + return; + } + var dy = e.clientY - this.startDragMouseY_; + this.startDragMouseY_ = e.clientY; + var y = metrics.viewTop - dy; + y = goog.math.clamp(y, 0, metrics.contentHeight - metrics.viewHeight); + this.scrollbar_.set(y); + } +}; + +/** + * Mouse button is down on a block in a non-closing flyout. Create the block + * if the mouse moves beyond a small radius. This allows one to play with + * fields without instantiating blocks that instantly self-destruct. + * @param {!Event} e Mouse move event. + * @private + */ +Blockly.Flyout.prototype.onMouseMoveBlock_ = function(e) { + if (e.type == 'mousemove' && e.clientX <= 1 && e.clientY == 0 && + e.button == 0) { + /* HACK: + Safari Mobile 6.0 and Chrome for Android 18.0 fire rogue mousemove events + on certain touch actions. Ignore events with these signatures. + This may result in a one-pixel blind spot in other browsers, + but this shouldn't be noticeable. */ + e.stopPropagation(); + return; + } + var dx = e.clientX - Blockly.Flyout.startDownEvent_.clientX; + var dy = e.clientY - Blockly.Flyout.startDownEvent_.clientY; + + var createBlock = this.determineDragIntention_(dx, dy); + if (createBlock) { + this.createBlockFunc_(Blockly.Flyout.startBlock_)( + Blockly.Flyout.startDownEvent_); + } else if (this.dragMode_ == Blockly.DRAG_FREE) { + // Do a scroll. + this.onMouseMove_(e); + } + e.stopPropagation(); +}; + +/** + * Determine the intention of a drag. + * Updates dragMode_ based on a drag delta and the current mode, + * and returns true if we should create a new block. + * @param {number} dx X delta of the drag. + * @param {number} dy Y delta of the drag. + * @return {boolean} True if a new block should be created. + * @private + */ +Blockly.Flyout.prototype.determineDragIntention_ = function(dx, dy) { + if (this.dragMode_ == Blockly.DRAG_FREE) { + // Once in free mode, always stay in free mode and never create a block. + return false; + } + var dragDistance = Math.sqrt(dx * dx + dy * dy); + if (dragDistance < this.DRAG_RADIUS) { + // Still within the sticky drag radius. + this.dragMode_ = Blockly.DRAG_STICKY; + return false; + } else { + if (this.isDragTowardWorkspace_(dx, dy) || !this.scrollbar_.isVisible()) { + // Immediately create a block. + return true; + } else { + // Immediately move to free mode - the drag is away from the workspace. + this.dragMode_ = Blockly.DRAG_FREE; + return false; + } + } +}; + +/** + * Determine if a drag delta is toward the workspace, based on the position + * and orientation of the flyout. This is used in determineDragIntention_ to + * determine if a new block should be created or if the flyout should scroll. + * @param {number} dx X delta of the drag. + * @param {number} dy Y delta of the drag. + * @return {boolean} true if the drag is toward the workspace. + * @private + */ +Blockly.Flyout.prototype.isDragTowardWorkspace_ = function(dx, dy) { + // Direction goes from -180 to 180, with 0 toward the right and 90 on top. + var dragDirection = Math.atan2(dy, dx) / Math.PI * 180; + + var draggingTowardWorkspace = false; + var range = this.dragAngleRange_; + if (this.horizontalLayout_) { + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_TOP) { + // Horizontal at top. + if (dragDirection < 90 + range && dragDirection > 90 - range) { + draggingTowardWorkspace = true; + } + } else { + // Horizontal at bottom. + if (dragDirection > -90 - range && dragDirection < -90 + range) { + draggingTowardWorkspace = true; + } + } + } else { + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_LEFT) { + // Vertical at left. + if (dragDirection < range && dragDirection > -range) { + draggingTowardWorkspace = true; + } + } else { + // Vertical at right. + if (dragDirection < -180 + range || dragDirection > 180 - range) { + draggingTowardWorkspace = true; + } + } + } + return draggingTowardWorkspace; +}; + +/** + * Create a copy of this block on the workspace. + * @param {!Blockly.Block} originBlock The flyout block to copy. + * @return {!Function} Function to call when block is clicked. + * @private + */ +Blockly.Flyout.prototype.createBlockFunc_ = function(originBlock) { + var flyout = this; + return function(e) { + if (Blockly.isRightButton(e)) { + // Right-click. Don't create a block, let the context menu show. + return; + } + if (originBlock.disabled) { + // Beyond capacity. + return; + } + Blockly.Events.disable(); + try { + var block = flyout.placeNewBlock_(originBlock); + } finally { + Blockly.Events.enable(); + } + if (Blockly.Events.isEnabled()) { + Blockly.Events.setGroup(true); + Blockly.Events.fire(new Blockly.Events.Create(block)); + } + if (flyout.autoClose) { + flyout.hide(); + } else { + flyout.filterForCapacity_(); + } + // Start a dragging operation on the new block. + block.onMouseDown_(e); + Blockly.dragMode_ = Blockly.DRAG_FREE; + block.setDragging_(true); + }; +}; + +/** + * Copy a block from the flyout to the workspace and position it correctly. + * @param {!Blockly.Block} originBlock The flyout block to copy.. + * @return {!Blockly.Block} The new block in the main workspace. + * @private + */ +Blockly.Flyout.prototype.placeNewBlock_ = function(originBlock) { + var targetWorkspace = this.targetWorkspace_; + var svgRootOld = originBlock.getSvgRoot(); + if (!svgRootOld) { + throw 'originBlock is not rendered.'; + } + // Figure out where the original block is on the screen, relative to the upper + // left corner of the main workspace. + var xyOld = Blockly.getSvgXY_(svgRootOld, targetWorkspace); + // Take into account that the flyout might have been scrolled horizontally + // (separately from the main workspace). + // Generally a no-op in vertical mode but likely to happen in horizontal + // mode. + var scrollX = this.workspace_.scrollX; + var scale = this.workspace_.scale; + xyOld.x += scrollX / scale - scrollX; + // If the flyout is on the right side, (0, 0) in the flyout is offset to + // the right of (0, 0) in the main workspace. Add an offset to take that + // into account. + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_RIGHT) { + scrollX = targetWorkspace.getMetrics().viewWidth - this.width_; + scale = targetWorkspace.scale; + // Scale the scroll (getSvgXY_ did not do this). + xyOld.x += scrollX / scale - scrollX; + } + + // Take into account that the flyout might have been scrolled vertically + // (separately from the main workspace). + // Generally a no-op in horizontal mode but likely to happen in vertical + // mode. + var scrollY = this.workspace_.scrollY; + scale = this.workspace_.scale; + xyOld.y += scrollY / scale - scrollY; + // If the flyout is on the bottom, (0, 0) in the flyout is offset to be below + // (0, 0) in the main workspace. Add an offset to take that into account. + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_BOTTOM) { + scrollY = targetWorkspace.getMetrics().viewHeight - this.height_; + scale = targetWorkspace.scale; + xyOld.y += scrollY / scale - scrollY; + } + + // Create the new block by cloning the block in the flyout (via XML). + var xml = Blockly.Xml.blockToDom(originBlock); + var block = Blockly.Xml.domToBlock(xml, targetWorkspace); + var svgRootNew = block.getSvgRoot(); + if (!svgRootNew) { + throw 'block is not rendered.'; + } + // Figure out where the new block got placed on the screen, relative to the + // upper left corner of the workspace. This may not be the same as the + // original block because the flyout's origin may not be the same as the + // main workspace's origin. + var xyNew = Blockly.getSvgXY_(svgRootNew, targetWorkspace); + // Scale the scroll (getSvgXY_ did not do this). + xyNew.x += + targetWorkspace.scrollX / targetWorkspace.scale - targetWorkspace.scrollX; + xyNew.y += + targetWorkspace.scrollY / targetWorkspace.scale - targetWorkspace.scrollY; + // If the flyout is collapsible and the workspace can't be scrolled. + if (targetWorkspace.toolbox_ && !targetWorkspace.scrollbar) { + xyNew.x += targetWorkspace.toolbox_.getWidth() / targetWorkspace.scale; + xyNew.y += targetWorkspace.toolbox_.getHeight() / targetWorkspace.scale; + } + + // Move the new block to where the old block is. + block.moveBy(xyOld.x - xyNew.x, xyOld.y - xyNew.y); + return block; +}; + +/** + * Filter the blocks on the flyout to disable the ones that are above the + * capacity limit. + * @private + */ +Blockly.Flyout.prototype.filterForCapacity_ = function() { + var remainingCapacity = this.targetWorkspace_.remainingCapacity(); + var blocks = this.workspace_.getTopBlocks(false); + for (var i = 0, block; block = blocks[i]; i++) { + if (this.permanentlyDisabled_.indexOf(block) == -1) { + var allBlocks = block.getDescendants(); + block.setDisabled(allBlocks.length > remainingCapacity); + } + } +}; + +/** + * Return the deletion rectangle for this flyout. + * @return {goog.math.Rect} Rectangle in which to delete. + */ +Blockly.Flyout.prototype.getClientRect = function() { + if (!this.svgGroup_) { + return null; + } + + var flyoutRect = this.svgGroup_.getBoundingClientRect(); + // BIG_NUM is offscreen padding so that blocks dragged beyond the shown flyout + // area are still deleted. Must be larger than the largest screen size, + // but be smaller than half Number.MAX_SAFE_INTEGER (not available on IE). + var BIG_NUM = 1000000000; + var x = flyoutRect.left; + var y = flyoutRect.top; + var width = flyoutRect.width; + var height = flyoutRect.height; + + if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_TOP) { + return new goog.math.Rect(-BIG_NUM, y - BIG_NUM, BIG_NUM * 2, + BIG_NUM + height); + } else if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_BOTTOM) { + return new goog.math.Rect(-BIG_NUM, y, BIG_NUM * 2, + BIG_NUM + height); + } else if (this.toolboxPosition_ == Blockly.TOOLBOX_AT_LEFT) { + return new goog.math.Rect(x - BIG_NUM, -BIG_NUM, BIG_NUM + width, + BIG_NUM * 2); + } else { // Right + return new goog.math.Rect(x, -BIG_NUM, BIG_NUM + width, BIG_NUM * 2); + } +}; + +/** + * Stop binding to the global mouseup and mousemove events. + * @private + */ +Blockly.Flyout.terminateDrag_ = function() { + if (Blockly.Flyout.startFlyout_) { + Blockly.Flyout.startFlyout_.dragMode_ = Blockly.DRAG_NONE; + } + if (Blockly.Flyout.onMouseUpWrapper_) { + Blockly.unbindEvent_(Blockly.Flyout.onMouseUpWrapper_); + Blockly.Flyout.onMouseUpWrapper_ = null; + } + if (Blockly.Flyout.onMouseMoveBlockWrapper_) { + Blockly.unbindEvent_(Blockly.Flyout.onMouseMoveBlockWrapper_); + Blockly.Flyout.onMouseMoveBlockWrapper_ = null; + } + if (Blockly.Flyout.onMouseMoveWrapper_) { + Blockly.unbindEvent_(Blockly.Flyout.onMouseMoveWrapper_); + Blockly.Flyout.onMouseMoveWrapper_ = null; + } + Blockly.Flyout.startDownEvent_ = null; + Blockly.Flyout.startBlock_ = null; + Blockly.Flyout.startFlyout_ = null; +}; + +/** + * Compute height of flyout. Position button under each block. + * For RTL: Lay out the blocks right-aligned. + * @param {!Array<!Blockly.Block>} blocks The blocks to reflow. + */ +Blockly.Flyout.prototype.reflowHorizontal = function(blocks) { + this.workspace_.scale = this.targetWorkspace_.scale; + var flyoutHeight = 0; + for (var i = 0, block; block = blocks[i]; i++) { + flyoutHeight = Math.max(flyoutHeight, block.getHeightWidth().height); + } + flyoutHeight += this.MARGIN * 1.5; + flyoutHeight *= this.workspace_.scale; + flyoutHeight += Blockly.Scrollbar.scrollbarThickness; + if (this.height_ != flyoutHeight) { + for (var i = 0, block; block = blocks[i]; i++) { + var blockHW = block.getHeightWidth(); + if (block.flyoutRect_) { + block.flyoutRect_.setAttribute('width', blockHW.width); + block.flyoutRect_.setAttribute('height', blockHW.height); + // Rectangles behind blocks with output tabs are shifted a bit. + var tab = block.outputConnection ? Blockly.BlockSvg.TAB_WIDTH : 0; + var blockXY = block.getRelativeToSurfaceXY(); + block.flyoutRect_.setAttribute('y', blockXY.y); + block.flyoutRect_.setAttribute('x', + this.RTL ? blockXY.x - blockHW.width + tab : blockXY.x - tab); + // For hat blocks we want to shift them down by the hat height + // since the y coordinate is the corner, not the top of the hat. + var hatOffset = + block.startHat_ ? Blockly.BlockSvg.START_HAT_HEIGHT : 0; + if (hatOffset) { + block.moveBy(0, hatOffset); + } + block.flyoutRect_.setAttribute('y', blockXY.y); + } + } + // Record the height for .getMetrics_ and .position. + this.height_ = flyoutHeight; + // Call this since it is possible the trash and zoom buttons need + // to move. e.g. on a bottom positioned flyout when zoom is clicked. + this.targetWorkspace_.resize(); + } +}; + +/** + * Compute width of flyout. Position button under each block. + * For RTL: Lay out the blocks right-aligned. + * @param {!Array<!Blockly.Block>} blocks The blocks to reflow. + */ +Blockly.Flyout.prototype.reflowVertical = function(blocks) { + this.workspace_.scale = this.targetWorkspace_.scale; + var flyoutWidth = 0; + for (var i = 0, block; block = blocks[i]; i++) { + var width = block.getHeightWidth().width; + if (block.outputConnection) { + width -= Blockly.BlockSvg.TAB_WIDTH; + } + flyoutWidth = Math.max(flyoutWidth, width); + } + for (var i = 0, button; button = this.buttons_[i]; i++) { + flyoutWidth = Math.max(flyoutWidth, button.width); + } + flyoutWidth += this.MARGIN * 1.5 + Blockly.BlockSvg.TAB_WIDTH; + flyoutWidth *= this.workspace_.scale; + flyoutWidth += Blockly.Scrollbar.scrollbarThickness; + if (this.width_ != flyoutWidth) { + for (var i = 0, block; block = blocks[i]; i++) { + var blockHW = block.getHeightWidth(); + if (this.RTL) { + // With the flyoutWidth known, right-align the blocks. + var oldX = block.getRelativeToSurfaceXY().x; + var newX = flyoutWidth / this.workspace_.scale - this.MARGIN; + newX -= Blockly.BlockSvg.TAB_WIDTH; + block.moveBy(newX - oldX, 0); + } + if (block.flyoutRect_) { + block.flyoutRect_.setAttribute('width', blockHW.width); + block.flyoutRect_.setAttribute('height', blockHW.height); + // Blocks with output tabs are shifted a bit. + var tab = block.outputConnection ? Blockly.BlockSvg.TAB_WIDTH : 0; + var blockXY = block.getRelativeToSurfaceXY(); + block.flyoutRect_.setAttribute('x', + this.RTL ? blockXY.x - blockHW.width + tab : blockXY.x - tab); + // For hat blocks we want to shift them down by the hat height + // since the y coordinate is the corner, not the top of the hat. + var hatOffset = + block.startHat_ ? Blockly.BlockSvg.START_HAT_HEIGHT : 0; + if (hatOffset) { + block.moveBy(0, hatOffset); + } + block.flyoutRect_.setAttribute('y', blockXY.y); + } + } + // Record the width for .getMetrics_ and .position. + this.width_ = flyoutWidth; + // Call this since it is possible the trash and zoom buttons need + // to move. e.g. on a bottom positioned flyout when zoom is clicked. + this.targetWorkspace_.resize(); + } +}; + +/** + * Reflow blocks and their buttons. + */ +Blockly.Flyout.prototype.reflow = function() { + if (this.reflowWrapper_) { + this.workspace_.removeChangeListener(this.reflowWrapper_); + } + var blocks = this.workspace_.getTopBlocks(false); + if (this.horizontalLayout_) { + this.reflowHorizontal(blocks); + } else { + this.reflowVertical(blocks); + } + if (this.reflowWrapper_) { + this.workspace_.addChangeListener(this.reflowWrapper_); + } +}; |