diff --git a/core/flyout_base.js b/core/flyout_base.js index 5e8f1c6cc..4490617e0 100644 --- a/core/flyout_base.js +++ b/core/flyout_base.js @@ -24,7 +24,6 @@ const common = goog.require('Blockly.common'); const dom = goog.require('Blockly.utils.dom'); const eventUtils = goog.require('Blockly.Events.utils'); const idGenerator = goog.require('Blockly.utils.idGenerator'); -const object = goog.require('Blockly.utils.object'); const toolbox = goog.require('Blockly.utils.toolbox'); /* eslint-disable-next-line no-unused-vars */ const {BlockSvg} = goog.requireType('Blockly.BlockSvg'); @@ -59,1076 +58,1122 @@ goog.require('Blockly.blockRendering'); /** * Class for a flyout. - * @param {!Options} workspaceOptions Dictionary of options for the - * workspace. - * @constructor * @abstract * @implements {IFlyout} * @extends {DeleteArea} - * @alias Blockly.Flyout */ -const Flyout = function(workspaceOptions) { - Flyout.superClass_.constructor.call(this); - workspaceOptions.setMetrics = this.setMetrics_.bind(this); - +class Flyout extends DeleteArea { /** - * @type {!WorkspaceSvg} - * @protected + * @param {!Options} workspaceOptions Dictionary of options for the + * workspace. + * @alias Blockly.Flyout */ - this.workspace_ = new WorkspaceSvg(workspaceOptions); - this.workspace_.setMetricsManager( - new FlyoutMetricsManager(this.workspace_, this)); + constructor(workspaceOptions) { + super(); + workspaceOptions.setMetrics = this.setMetrics_.bind(this); - this.workspace_.isFlyout = true; - // Keep the workspace visibility consistent with the flyout's visibility. - this.workspace_.setVisible(this.isVisible_); + /** + * @type {!WorkspaceSvg} + * @protected + */ + this.workspace_ = new WorkspaceSvg(workspaceOptions); + this.workspace_.setMetricsManager( + new FlyoutMetricsManager(this.workspace_, this)); - /** - * The unique id for this component that is used to register with the - * ComponentManager. - * @type {string} - */ - this.id = idGenerator.genUid(); + this.workspace_.isFlyout = true; + // Keep the workspace visibility consistent with the flyout's visibility. + this.workspace_.setVisible(this.isVisible_); - /** - * Is RTL vs LTR. - * @type {boolean} - */ - this.RTL = !!workspaceOptions.RTL; + /** + * The unique id for this component that is used to register with the + * ComponentManager. + * @type {string} + */ + this.id = idGenerator.genUid(); - /** - * Whether the flyout should be laid out horizontally or not. - * @type {boolean} - * @package - */ - this.horizontalLayout = false; + /** + * Is RTL vs LTR. + * @type {boolean} + */ + this.RTL = !!workspaceOptions.RTL; - /** - * Position of the toolbox and flyout relative to the workspace. - * @type {number} - * @protected - */ - this.toolboxPosition_ = workspaceOptions.toolboxPosition; + /** + * Whether the flyout should be laid out horizontally or not. + * @type {boolean} + * @package + */ + this.horizontalLayout = false; - /** - * Opaque data that can be passed to Blockly.unbindEvent_. - * @type {!Array} - * @private - */ - this.eventWrappers_ = []; + /** + * Position of the toolbox and flyout relative to the workspace. + * @type {number} + * @protected + */ + this.toolboxPosition_ = workspaceOptions.toolboxPosition; - /** - * List of background mats that lurk behind each block to catch clicks - * landing in the blocks' lakes and bays. - * @type {!Array} - * @private - */ - this.mats_ = []; + /** + * Opaque data that can be passed to Blockly.unbindEvent_. + * @type {!Array} + * @private + */ + this.eventWrappers_ = []; - /** - * List of visible buttons. - * @type {!Array} - * @protected - */ - this.buttons_ = []; + /** + * Function that will be registered as a change listener on the workspace + * to reflow when blocks in the flyout workspace change. + * @type {?Function} + * @private + */ + this.reflowWrapper_ = null; - /** - * List of event listeners. - * @type {!Array} - * @private - */ - this.listeners_ = []; - - /** - * List of blocks that should always be disabled. - * @type {!Array} - * @private - */ - this.permanentlyDisabled_ = []; - - /** - * Width of output tab. - * @type {number} - * @protected - * @const - */ - this.tabWidth_ = this.workspace_.getRenderer().getConstants().TAB_WIDTH; - - /** - * The target workspace - * @type {?WorkspaceSvg} - * @package - */ - this.targetWorkspace = null; - - /** - * A list of blocks that can be reused. - * @type {!Array} - * @private - */ - this.recycledBlocks_ = []; - - /** - * Does the flyout automatically close when a block is created? - * @type {boolean} - */ - this.autoClose = true; - - /** - * Whether the flyout is visible. - * @type {boolean} - * @private - */ - this.isVisible_ = false; - - /** - * Whether the workspace containing this flyout is visible. - * @type {boolean} - * @private - */ - this.containerVisible_ = true; - - /** - * Corner radius of the flyout background. - * @type {number} - * @const - */ - this.CORNER_RADIUS = 8; - - /** - * Margin around the edges of the blocks in the flyout. - * @type {number} - * @const - */ - this.MARGIN = this.CORNER_RADIUS; - - // TODO: Move GAP_X and GAP_Y to their appropriate files. - - /** - * Gap between items in horizontal flyouts. Can be overridden with the "sep" - * element. - * @const {number} - */ - this.GAP_X = this.MARGIN * 3; - - /** - * Gap between items in vertical flyouts. Can be overridden with the "sep" - * element. - * @const {number} - */ - this.GAP_Y = this.MARGIN * 3; - - /** - * Top/bottom padding between scrollbar and edge of flyout background. - * @type {number} - * @const - */ - this.SCROLLBAR_MARGIN = 2.5; - - /** - * Width of flyout. - * @type {number} - * @protected - */ - this.width_ = 0; - - /** - * Height of flyout. - * @type {number} - * @protected - */ - this.height_ = 0; - - /** - * 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} - * @protected - */ - this.dragAngleRange_ = 70; -}; -object.inherits(Flyout, DeleteArea); - -/** - * Creates the flyout's DOM. Only needs to be called once. The flyout can - * either exist as its own SVG element or be a g element nested inside a - * separate SVG element. - * @param {string| - * !Svg| - * !Svg} tagName The type of tag to - * put the flyout in. This should be or . - * @return {!SVGElement} The flyout's SVG group. - */ -Flyout.prototype.createDom = function(tagName) { - /* - - - - - */ - // Setting style to display:none to start. The toolbox and flyout - // hide/show code will set up proper visibility and size later. - this.svgGroup_ = dom.createSvgElement( - tagName, {'class': 'blocklyFlyout', 'style': 'display: none'}, null); - this.svgBackground_ = dom.createSvgElement( - Svg.PATH, {'class': 'blocklyFlyoutBackground'}, this.svgGroup_); - this.svgGroup_.appendChild(this.workspace_.createDom()); - this.workspace_.getThemeManager().subscribe( - this.svgBackground_, 'flyoutBackgroundColour', 'fill'); - this.workspace_.getThemeManager().subscribe( - this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); - return this.svgGroup_; -}; - -/** - * Initializes the flyout. - * @param {!WorkspaceSvg} targetWorkspace The workspace in which to - * create new blocks. - */ -Flyout.prototype.init = function(targetWorkspace) { - this.targetWorkspace = targetWorkspace; - this.workspace_.targetWorkspace = targetWorkspace; - - this.workspace_.scrollbar = new ScrollbarPair( - this.workspace_, this.horizontalLayout, !this.horizontalLayout, - 'blocklyFlyoutScrollbar', this.SCROLLBAR_MARGIN); - - this.hide(); - - Array.prototype.push.apply( - this.eventWrappers_, - browserEvents.conditionalBind( - 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_, - browserEvents.conditionalBind( - this.svgBackground_, 'mousedown', this, this.onMouseDown_)); - - // A flyout connected to a workspace doesn't have its own current gesture. - this.workspace_.getGesture = - this.targetWorkspace.getGesture.bind(this.targetWorkspace); - - // Get variables from the main workspace rather than the target workspace. - this.workspace_.setVariableMap(this.targetWorkspace.getVariableMap()); - - this.workspace_.createPotentialVariableMap(); - - targetWorkspace.getComponentManager().addComponent({ - component: this, - weight: 1, - capabilities: [ - ComponentManager.Capability.DELETE_AREA, - ComponentManager.Capability.DRAG_TARGET, - ], - }); -}; - -/** - * Dispose of this flyout. - * Unlink from all DOM elements to prevent memory leaks. - * @suppress {checkTypes} - */ -Flyout.prototype.dispose = function() { - this.hide(); - this.workspace_.getComponentManager().removeComponent(this.id); - browserEvents.unbind(this.eventWrappers_); - if (this.filterWrapper_) { - this.targetWorkspace.removeChangeListener(this.filterWrapper_); + /** + * Function that disables blocks in the flyout based on max block counts + * allowed in the target workspace. Registered as a change listener on the + * target workspace. + * @type {?Function} + * @private + */ this.filterWrapper_ = null; - } - if (this.workspace_) { - this.workspace_.getThemeManager().unsubscribe(this.svgBackground_); - this.workspace_.targetWorkspace = null; - this.workspace_.dispose(); - this.workspace_ = null; - } - if (this.svgGroup_) { - dom.removeNode(this.svgGroup_); + + /** + * List of background mats that lurk behind each block to catch clicks + * landing in the blocks' lakes and bays. + * @type {!Array} + * @private + */ + this.mats_ = []; + + /** + * List of visible buttons. + * @type {!Array} + * @protected + */ + this.buttons_ = []; + + /** + * List of event listeners. + * @type {!Array} + * @private + */ + this.listeners_ = []; + + /** + * List of blocks that should always be disabled. + * @type {!Array} + * @private + */ + this.permanentlyDisabled_ = []; + + /** + * Width of output tab. + * @type {number} + * @protected + * @const + */ + this.tabWidth_ = this.workspace_.getRenderer().getConstants().TAB_WIDTH; + + /** + * The target workspace + * @type {?WorkspaceSvg} + * @package + */ + this.targetWorkspace = null; + + /** + * A list of blocks that can be reused. + * @type {!Array} + * @private + */ + this.recycledBlocks_ = []; + + /** + * Does the flyout automatically close when a block is created? + * @type {boolean} + */ + this.autoClose = true; + + /** + * Whether the flyout is visible. + * @type {boolean} + * @private + */ + this.isVisible_ = false; + + /** + * Whether the workspace containing this flyout is visible. + * @type {boolean} + * @private + */ + this.containerVisible_ = true; + + /** + * Corner radius of the flyout background. + * @type {number} + * @const + */ + this.CORNER_RADIUS = 8; + + /** + * Margin around the edges of the blocks in the flyout. + * @type {number} + * @const + */ + this.MARGIN = this.CORNER_RADIUS; + + // TODO: Move GAP_X and GAP_Y to their appropriate files. + + /** + * Gap between items in horizontal flyouts. Can be overridden with the "sep" + * element. + * @const {number} + */ + this.GAP_X = this.MARGIN * 3; + + /** + * Gap between items in vertical flyouts. Can be overridden with the "sep" + * element. + * @const {number} + */ + this.GAP_Y = this.MARGIN * 3; + + /** + * Top/bottom padding between scrollbar and edge of flyout background. + * @type {number} + * @const + */ + this.SCROLLBAR_MARGIN = 2.5; + + /** + * Width of flyout. + * @type {number} + * @protected + */ + this.width_ = 0; + + /** + * Height of flyout. + * @type {number} + * @protected + */ + this.height_ = 0; + + // clang-format off + /** + * 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} + * @protected + */ + // clang-format on + this.dragAngleRange_ = 70; + + /** + * The path around the background of the flyout, which will be filled with a + * background colour. + * @type {?SVGPathElement} + * @protected + */ + this.svgBackground_ = null; + + /** + * The root SVG group for the button or label. + * @type {?SVGGElement} + * @protected + */ this.svgGroup_ = null; } - this.svgBackground_ = null; - this.targetWorkspace = null; -}; -/** - * Get the width of the flyout. - * @return {number} The width of the flyout. - */ -Flyout.prototype.getWidth = function() { - return this.width_; -}; - -/** - * Get the height of the flyout. - * @return {number} The width of the flyout. - */ -Flyout.prototype.getHeight = function() { - return this.height_; -}; - -/** - * Get the scale (zoom level) of the flyout. By default, - * this matches the target workspace scale, but this can be overridden. - * @return {number} Flyout workspace scale. - */ -Flyout.prototype.getFlyoutScale = function() { - return this.targetWorkspace.scale; -}; - -/** - * Get the workspace inside the flyout. - * @return {!WorkspaceSvg} The workspace inside the flyout. - * @package - */ -Flyout.prototype.getWorkspace = function() { - return this.workspace_; -}; - -/** - * Is the flyout visible? - * @return {boolean} True if visible. - */ -Flyout.prototype.isVisible = function() { - return this.isVisible_; -}; - -/** - * Set whether the flyout is visible. A value of true does not necessarily mean - * that the flyout is shown. It could be hidden because its container is hidden. - * @param {boolean} visible True if visible. - */ -Flyout.prototype.setVisible = function(visible) { - const visibilityChanged = (visible !== this.isVisible()); - - this.isVisible_ = visible; - if (visibilityChanged) { - if (!this.autoClose) { - // Auto-close flyouts are ignored as drag targets, so only non auto-close - // flyouts need to have their drag target updated. - this.workspace_.recordDragTargets(); - } - this.updateDisplay_(); - } -}; - -/** - * Set whether this flyout's container is visible. - * @param {boolean} visible Whether the container is visible. - */ -Flyout.prototype.setContainerVisible = function(visible) { - const visibilityChanged = (visible !== this.containerVisible_); - this.containerVisible_ = visible; - if (visibilityChanged) { - this.updateDisplay_(); - } -}; - -/** - * Update the display property of the flyout based whether it thinks it should - * be visible and whether its containing workspace is visible. - * @private - */ -Flyout.prototype.updateDisplay_ = function() { - let show = true; - if (!this.containerVisible_) { - show = false; - } else { - show = this.isVisible(); - } - this.svgGroup_.style.display = show ? 'block' : 'none'; - // Update the scrollbar's visibility too since it should mimic the - // flyout's visibility. - this.workspace_.scrollbar.setContainerVisible(show); -}; - -/** - * Update the view based on coordinates calculated in position(). - * @param {number} width The computed width of the flyout's SVG group - * @param {number} height The computed height of the flyout's SVG group. - * @param {number} x The computed x origin of the flyout's SVG group. - * @param {number} y The computed y origin of the flyout's SVG group. - * @protected - */ -Flyout.prototype.positionAt_ = function(width, height, x, y) { - this.svgGroup_.setAttribute('width', width); - this.svgGroup_.setAttribute('height', height); - this.workspace_.setCachedParentSvgSize(width, height); - - if (this.svgGroup_.tagName === 'svg') { - const transform = 'translate(' + x + 'px,' + y + 'px)'; - dom.setCssTransform(this.svgGroup_, transform); - } else { - // IE and Edge don't support CSS transforms on SVG elements so - // it's important to set the transform on the SVG element itself - const transform = 'translate(' + x + ',' + y + ')'; - this.svgGroup_.setAttribute('transform', transform); + /** + * Creates the flyout's DOM. Only needs to be called once. The flyout can + * either exist as its own SVG element or be a g element nested inside a + * separate SVG element. + * @param {string| + * !Svg| + * !Svg} tagName The type of tag to + * put the flyout in. This should be or . + * @return {!SVGElement} The flyout's SVG group. + */ + createDom(tagName) { + /* + + + + + */ + // Setting style to display:none to start. The toolbox and flyout + // hide/show code will set up proper visibility and size later. + this.svgGroup_ = dom.createSvgElement( + tagName, {'class': 'blocklyFlyout', 'style': 'display: none'}, null); + this.svgBackground_ = dom.createSvgElement( + Svg.PATH, {'class': 'blocklyFlyoutBackground'}, this.svgGroup_); + this.svgGroup_.appendChild(this.workspace_.createDom()); + this.workspace_.getThemeManager().subscribe( + this.svgBackground_, 'flyoutBackgroundColour', 'fill'); + this.workspace_.getThemeManager().subscribe( + this.svgBackground_, 'flyoutOpacity', 'fill-opacity'); + return this.svgGroup_; } - // Update the scrollbar (if one exists). - const scrollbar = this.workspace_.scrollbar; - if (scrollbar) { - // Set the scrollbars origin to be the top left of the flyout. - scrollbar.setOrigin(x, y); - scrollbar.resize(); - // If origin changed and metrics haven't changed enough to trigger - // reposition in resize, we need to call setPosition. See issue #4692. - if (scrollbar.hScroll) { - scrollbar.hScroll.setPosition( - scrollbar.hScroll.position.x, scrollbar.hScroll.position.y); - } - if (scrollbar.vScroll) { - scrollbar.vScroll.setPosition( - scrollbar.vScroll.position.x, scrollbar.vScroll.position.y); - } - } -}; + /** + * Initializes the flyout. + * @param {!WorkspaceSvg} targetWorkspace The workspace in which to + * create new blocks. + */ + init(targetWorkspace) { + this.targetWorkspace = targetWorkspace; + this.workspace_.targetWorkspace = targetWorkspace; -/** - * Hide and empty the flyout. - */ -Flyout.prototype.hide = function() { - if (!this.isVisible()) { - return; - } - this.setVisible(false); - // Delete all the event listeners. - for (let i = 0, listen; (listen = this.listeners_[i]); i++) { - browserEvents.unbind(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/ -}; + this.workspace_.scrollbar = new ScrollbarPair( + this.workspace_, this.horizontalLayout, !this.horizontalLayout, + 'blocklyFlyoutScrollbar', this.SCROLLBAR_MARGIN); -/** - * Show and populate the flyout. - * @param {!toolbox.FlyoutDefinition|string} flyoutDef Contents to display - * in the flyout. This is either an array of Nodes, a NodeList, a - * toolbox definition, or a string with the name of the dynamic category. - */ -Flyout.prototype.show = function(flyoutDef) { - this.workspace_.setResizesEnabled(false); - this.hide(); - this.clearOldBlocks_(); - - // Handle dynamic categories, represented by a name instead of a list. - if (typeof flyoutDef === 'string') { - flyoutDef = this.getDynamicCategoryContents_(flyoutDef); - } - this.setVisible(true); - - // Parse the Array, Node or NodeList into a a list of flyout items. - const parsedContent = toolbox.convertFlyoutDefToJsonArray(flyoutDef); - const flyoutInfo = - /** @type {{contents:!Array, gaps:!Array}} */ ( - this.createFlyoutInfo_(parsedContent)); - - this.layout_(flyoutInfo.contents, flyoutInfo.gaps); - - // IE 11 is an incompetent browser that fails to fire mouseout events. - // When the mouse is over the background, deselect all blocks. - const deselectAll = - /** @this {Flyout} */ - function() { - const topBlocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = topBlocks[i]); i++) { - block.removeSelect(); - } - }; - - this.listeners_.push(browserEvents.conditionalBind( - this.svgBackground_, 'mouseover', this, deselectAll)); - - if (this.horizontalLayout) { - this.height_ = 0; - } else { - this.width_ = 0; - } - this.workspace_.setResizesEnabled(true); - 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_); - this.emptyRecycledBlocks_(); -}; - -/** - * Create the contents array and gaps array necessary to create the layout for - * the flyout. - * @param {!toolbox.FlyoutItemInfoArray} parsedContent The array - * of objects to show in the flyout. - * @return {{contents:Array, gaps:Array}} The list of contents - * and gaps needed to lay out the flyout. - * @private - */ -Flyout.prototype.createFlyoutInfo_ = function(parsedContent) { - const contents = []; - const gaps = []; - this.permanentlyDisabled_.length = 0; - const defaultGap = this.horizontalLayout ? this.GAP_X : this.GAP_Y; - for (let i = 0, contentInfo; (contentInfo = parsedContent[i]); i++) { - if (contentInfo['custom']) { - const customInfo = - /** @type {!toolbox.DynamicCategoryInfo} */ (contentInfo); - const categoryName = customInfo['custom']; - const flyoutDef = this.getDynamicCategoryContents_(categoryName); - const parsedDynamicContent = /** @type {!toolbox.FlyoutItemInfoArray} */ - (toolbox.convertFlyoutDefToJsonArray(flyoutDef)); - // Replace the element at i with the dynamic content it represents. - parsedContent.splice.apply( - parsedContent, [i, 1].concat(parsedDynamicContent)); - contentInfo = parsedContent[i]; - } - - switch (contentInfo['kind'].toUpperCase()) { - case 'BLOCK': { - const blockInfo = /** @type {!toolbox.BlockInfo} */ (contentInfo); - const block = this.createFlyoutBlock_(blockInfo); - contents.push({type: 'block', block: block}); - this.addBlockGap_(blockInfo, gaps, defaultGap); - break; - } - case 'SEP': { - const sepInfo = /** @type {!toolbox.SeparatorInfo} */ (contentInfo); - this.addSeparatorGap_(sepInfo, gaps, defaultGap); - break; - } - case 'LABEL': { - const labelInfo = /** @type {!toolbox.LabelInfo} */ (contentInfo); - // A label is a button with different styling. - const label = this.createButton_(labelInfo, /** isLabel */ true); - contents.push({type: 'button', button: label}); - gaps.push(defaultGap); - break; - } - case 'BUTTON': { - const buttonInfo = /** @type {!toolbox.ButtonInfo} */ (contentInfo); - const button = this.createButton_(buttonInfo, /** isLabel */ false); - contents.push({type: 'button', button: button}); - gaps.push(defaultGap); - break; - } - } - } - return {contents: contents, gaps: gaps}; -}; - -/** - * Gets the flyout definition for the dynamic category. - * @param {string} categoryName The name of the dynamic category. - * @return {!toolbox.FlyoutDefinition} The definition of the - * flyout in one of its many forms. - * @private - */ -Flyout.prototype.getDynamicCategoryContents_ = function(categoryName) { - // Look up the correct category generation function and call that to get a - // valid XML list. - const fnToApply = - this.workspace_.targetWorkspace.getToolboxCategoryCallback(categoryName); - if (typeof fnToApply !== 'function') { - throw TypeError( - 'Couldn\'t find a callback function when opening' + - ' a toolbox category.'); - } - return fnToApply(this.workspace_.targetWorkspace); -}; - -/** - * Creates a flyout button or a flyout label. - * @param {!toolbox.ButtonOrLabelInfo} btnInfo - * The object holding information about a button or a label. - * @param {boolean} isLabel True if the button is a label, false otherwise. - * @return {!FlyoutButton} The object used to display the button in the - * flyout. - * @private - */ -Flyout.prototype.createButton_ = function(btnInfo, isLabel) { - const {FlyoutButton} = goog.module.get('Blockly.FlyoutButton'); - if (!FlyoutButton) { - throw Error('Missing require for Blockly.FlyoutButton'); - } - const curButton = new FlyoutButton( - this.workspace_, - /** @type {!WorkspaceSvg} */ (this.targetWorkspace), btnInfo, isLabel); - return curButton; -}; - -/** - * Create a block from the xml and permanently disable any blocks that were - * defined as disabled. - * @param {!toolbox.BlockInfo} blockInfo The info of the block. - * @return {!BlockSvg} The block created from the blockInfo. - * @private - */ -Flyout.prototype.createFlyoutBlock_ = function(blockInfo) { - let block; - if (blockInfo['blockxml']) { - const xml = typeof blockInfo['blockxml'] === 'string' ? - Xml.textToDom(blockInfo['blockxml']) : - blockInfo['blockxml']; - block = this.getRecycledBlock_(xml.getAttribute('type')); - if (!block) { - block = Xml.domToBlock(xml, this.workspace_); - } - } else { - block = this.getRecycledBlock_(blockInfo['type']); - if (!block) { - if (blockInfo['enabled'] === undefined) { - blockInfo['enabled'] = - blockInfo['disabled'] !== 'true' && blockInfo['disabled'] !== true; - } - block = blocks.append( - /** @type {blocks.State} */ (blockInfo), this.workspace_); - } - } - - if (!block.isEnabled()) { - // Record blocks that were initially disabled. - // Do not enable these blocks as a result of capacity filtering. - this.permanentlyDisabled_.push(block); - } - return /** @type {!BlockSvg} */ (block); -}; - -/** - * Returns a block from the array of recycled blocks with the given type, or - * undefined if one cannot be found. - * @param {string} blockType The type of the block to try to recycle. - * @return {(!BlockSvg|undefined)} The recycled block, or undefined if - * one could not be recycled. - * @private - */ -Flyout.prototype.getRecycledBlock_ = function(blockType) { - let index = -1; - for (let i = 0; i < this.recycledBlocks_.length; i++) { - if (this.recycledBlocks_[i].type === blockType) { - index = i; - break; - } - } - return index === -1 ? undefined : this.recycledBlocks_.splice(index, 1)[0]; -}; - -/** - * Adds a gap in the flyout based on block info. - * @param {!toolbox.BlockInfo} blockInfo Information about a block. - * @param {!Array} gaps The list of gaps between items in the flyout. - * @param {number} defaultGap The default gap between one element and the next. - * @private - */ -Flyout.prototype.addBlockGap_ = function(blockInfo, gaps, defaultGap) { - let gap; - if (blockInfo['gap']) { - gap = parseInt(blockInfo['gap'], 10); - } else if (blockInfo['blockxml']) { - const xml = typeof blockInfo['blockxml'] === 'string' ? - Xml.textToDom(blockInfo['blockxml']) : - blockInfo['blockxml']; - gap = parseInt(xml.getAttribute('gap'), 10); - } - gaps.push(isNaN(gap) ? defaultGap : gap); -}; - -/** - * Add the necessary gap in the flyout for a separator. - * @param {!toolbox.SeparatorInfo} sepInfo The object holding - * information about a separator. - * @param {!Array} gaps The list gaps between items in the flyout. - * @param {number} defaultGap The default gap between the button and next - * element. - * @private - */ -Flyout.prototype.addSeparatorGap_ = function(sepInfo, gaps, defaultGap) { - // Change the gap between two toolbox elements. - // - // The default gap is 24, can be set larger or smaller. - // This overwrites the gap attribute on the previous element. - const newGap = parseInt(sepInfo['gap'], 10); - // Ignore gaps before the first block. - if (!isNaN(newGap) && gaps.length > 0) { - gaps[gaps.length - 1] = newGap; - } else { - gaps.push(defaultGap); - } -}; - -/** - * Delete blocks, mats and buttons from a previous showing of the flyout. - * @private - */ -Flyout.prototype.clearOldBlocks_ = function() { - // Delete any blocks from a previous showing. - const oldBlocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = oldBlocks[i]); i++) { - if (this.blockIsRecyclable_(block)) { - this.recycleBlock_(block); - } else { - block.dispose(false, false); - } - } - // Delete any mats from a previous showing. - for (let j = 0; j < this.mats_.length; j++) { - const rect = this.mats_[j]; - if (rect) { - Tooltip.unbindMouseEvents(rect); - dom.removeNode(rect); - } - } - this.mats_.length = 0; - // Delete any buttons from a previous showing. - for (let i = 0, button; (button = this.buttons_[i]); i++) { - button.dispose(); - } - this.buttons_.length = 0; - - // Clear potential variables from the previous showing. - this.workspace_.getPotentialVariableMap().clear(); -}; - -/** - * Empties all of the recycled blocks, properly disposing of them. - * @private - */ -Flyout.prototype.emptyRecycledBlocks_ = function() { - for (let i = 0; i < this.recycledBlocks_.length; i++) { - this.recycledBlocks_[i].dispose(); - } - this.recycledBlocks_ = []; -}; - -/** - * Returns whether the given block can be recycled or not. - * @param {!BlockSvg} _block The block to check for recyclability. - * @return {boolean} True if the block can be recycled. False otherwise. - * @protected - */ -Flyout.prototype.blockIsRecyclable_ = function(_block) { - // By default, recycling is disabled. - return false; -}; - -/** - * Puts a previously created block into the recycle bin and moves it to the - * top of the workspace. Used during large workspace swaps to limit the number - * of new DOM elements we need to create. - * @param {!BlockSvg} block The block to recycle. - * @private - */ -Flyout.prototype.recycleBlock_ = function(block) { - const xy = block.getRelativeToSurfaceXY(); - block.moveBy(-xy.x, -xy.y); - this.recycledBlocks_.push(block); -}; - -/** - * Add listeners to a block that has been added to the flyout. - * @param {!SVGElement} root The root node of the SVG group the block is in. - * @param {!BlockSvg} block The block to add listeners for. - * @param {!SVGElement} rect The invisible rectangle under the block that acts - * as a mat for that block. - * @protected - */ -Flyout.prototype.addBlockListeners_ = function(root, block, rect) { - this.listeners_.push(browserEvents.conditionalBind( - root, 'mousedown', null, this.blockMouseDown_(block))); - this.listeners_.push(browserEvents.conditionalBind( - rect, 'mousedown', null, this.blockMouseDown_(block))); - this.listeners_.push( - browserEvents.bind(root, 'mouseenter', block, block.addSelect)); - this.listeners_.push( - browserEvents.bind(root, 'mouseleave', block, block.removeSelect)); - this.listeners_.push( - browserEvents.bind(rect, 'mouseenter', block, block.addSelect)); - this.listeners_.push( - browserEvents.bind(rect, 'mouseleave', block, block.removeSelect)); -}; - -/** - * Handle a mouse-down on an SVG block in a non-closing flyout. - * @param {!BlockSvg} block The flyout block to copy. - * @return {!Function} Function to call when block is clicked. - * @private - */ -Flyout.prototype.blockMouseDown_ = function(block) { - const flyout = this; - return function(e) { - const gesture = flyout.targetWorkspace.getGesture(e); - if (gesture) { - gesture.setStartBlock(block); - gesture.handleFlyoutStart(e, flyout); - } - }; -}; - -/** - * Mouse down on the flyout background. Start a vertical scroll drag. - * @param {!Event} e Mouse down event. - * @private - */ -Flyout.prototype.onMouseDown_ = function(e) { - const gesture = this.targetWorkspace.getGesture(e); - if (gesture) { - gesture.handleFlyoutStart(e, this); - } -}; - -/** - * Does this flyout allow you to create a new instance of the given block? - * Used for deciding if a block can be "dragged out of" the flyout. - * @param {!BlockSvg} block The block to copy from the flyout. - * @return {boolean} True if you can create a new instance of the block, false - * otherwise. - * @package - */ -Flyout.prototype.isBlockCreatable_ = function(block) { - return block.isEnabled(); -}; - -/** - * Create a copy of this block on the workspace. - * @param {!BlockSvg} originalBlock The block to copy from the flyout. - * @return {!BlockSvg} The newly created block. - * @throws {Error} if something went wrong with deserialization. - * @package - */ -Flyout.prototype.createBlock = function(originalBlock) { - let newBlock = null; - eventUtils.disable(); - const variablesBeforeCreation = this.targetWorkspace.getAllVariables(); - this.targetWorkspace.setResizesEnabled(false); - try { - newBlock = this.placeNewBlock_(originalBlock); - } finally { - eventUtils.enable(); - } - - // Close the flyout. - this.targetWorkspace.hideChaff(); - - const newVariables = Variables.getAddedVariables( - this.targetWorkspace, variablesBeforeCreation); - - if (eventUtils.isEnabled()) { - eventUtils.setGroup(true); - // Fire a VarCreate event for each (if any) new variable created. - for (let i = 0; i < newVariables.length; i++) { - const thisVariable = newVariables[i]; - eventUtils.fire( - new (eventUtils.get(eventUtils.VAR_CREATE))(thisVariable)); - } - - // Block events come after var events, in case they refer to newly created - // variables. - eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(newBlock)); - } - if (this.autoClose) { this.hide(); - } else { - this.filterForCapacity_(); + + Array.prototype.push.apply( + this.eventWrappers_, + browserEvents.conditionalBind( + /** @type {!SVGGElement} */ (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_, + browserEvents.conditionalBind( + /** @type {!SVGPathElement} */ (this.svgBackground_), 'mousedown', + this, this.onMouseDown_)); + + // A flyout connected to a workspace doesn't have its own current gesture. + this.workspace_.getGesture = + this.targetWorkspace.getGesture.bind(this.targetWorkspace); + + // Get variables from the main workspace rather than the target workspace. + this.workspace_.setVariableMap(this.targetWorkspace.getVariableMap()); + + this.workspace_.createPotentialVariableMap(); + + targetWorkspace.getComponentManager().addComponent({ + component: this, + weight: 1, + capabilities: [ + ComponentManager.Capability.DELETE_AREA, + ComponentManager.Capability.DRAG_TARGET, + ], + }); } - return newBlock; -}; -/** - * Initialize the given button: move it to the correct location, - * add listeners, etc. - * @param {!FlyoutButton} button The button to initialize and place. - * @param {number} x The x position of the cursor during this layout pass. - * @param {number} y The y position of the cursor during this layout pass. - * @protected - */ -Flyout.prototype.initFlyoutButton_ = function(button, x, y) { - const buttonSvg = button.createDom(); - button.moveTo(x, y); - button.show(); - // Clicking on a flyout button or label is a lot like clicking on the - // flyout background. - this.listeners_.push(browserEvents.conditionalBind( - buttonSvg, 'mousedown', this, this.onMouseDown_)); + /** + * Dispose of this flyout. + * Unlink from all DOM elements to prevent memory leaks. + * @suppress {checkTypes} + */ + dispose() { + this.hide(); + this.workspace_.getComponentManager().removeComponent(this.id); + browserEvents.unbind(this.eventWrappers_); + if (this.filterWrapper_) { + this.targetWorkspace.removeChangeListener(this.filterWrapper_); + this.filterWrapper_ = null; + } + if (this.workspace_) { + this.workspace_.getThemeManager().unsubscribe(this.svgBackground_); + this.workspace_.targetWorkspace = null; + this.workspace_.dispose(); + this.workspace_ = null; + } + if (this.svgGroup_) { + dom.removeNode(this.svgGroup_); + this.svgGroup_ = null; + } + this.svgBackground_ = null; + this.targetWorkspace = null; + } - this.buttons_.push(button); -}; + /** + * Get the width of the flyout. + * @return {number} The width of the flyout. + */ + getWidth() { + return this.width_; + } -/** - * Create and place a rectangle corresponding to the given block. - * @param {!BlockSvg} block The block to associate the rect to. - * @param {number} x The x position of the cursor during this layout pass. - * @param {number} y The y position of the cursor during this layout pass. - * @param {!{height: number, width: number}} blockHW The height and width of the - * block. - * @param {number} index The index into the mats list where this rect should be - * placed. - * @return {!SVGElement} Newly created SVG element for the rectangle behind the - * block. - * @protected - */ -Flyout.prototype.createRect_ = function(block, x, y, blockHW, index) { - // 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. - const rect = dom.createSvgElement( - Svg.RECT, { - 'fill-opacity': 0, - 'x': x, - 'y': y, - 'height': blockHW.height, - 'width': blockHW.width, - }, - null); - rect.tooltip = block; - Tooltip.bindMouseEvents(rect); - // Add the rectangles under the blocks, so that the blocks' tooltips work. - this.workspace_.getCanvas().insertBefore(rect, block.getSvgRoot()); + /** + * Get the height of the flyout. + * @return {number} The width of the flyout. + */ + getHeight() { + return this.height_; + } - block.flyoutRect_ = rect; - this.mats_[index] = rect; - return rect; -}; + /** + * Get the scale (zoom level) of the flyout. By default, + * this matches the target workspace scale, but this can be overridden. + * @return {number} Flyout workspace scale. + */ + getFlyoutScale() { + return this.targetWorkspace.scale; + } -/** - * Move a rectangle to sit exactly behind a block, taking into account tabs, - * hats, and any other protrusions we invent. - * @param {!SVGElement} rect The rectangle to move directly behind the block. - * @param {!BlockSvg} block The block the rectangle should be behind. - * @protected - */ -Flyout.prototype.moveRectToBlock_ = function(rect, block) { - const blockHW = block.getHeightWidth(); - rect.setAttribute('width', blockHW.width); - rect.setAttribute('height', blockHW.height); + /** + * Get the workspace inside the flyout. + * @return {!WorkspaceSvg} The workspace inside the flyout. + * @package + */ + getWorkspace() { + return this.workspace_; + } - const blockXY = block.getRelativeToSurfaceXY(); - rect.setAttribute('y', blockXY.y); - rect.setAttribute('x', this.RTL ? blockXY.x - blockHW.width : blockXY.x); -}; + /** + * Is the flyout visible? + * @return {boolean} True if visible. + */ + isVisible() { + return this.isVisible_; + } -/** - * Filter the blocks on the flyout to disable the ones that are above the - * capacity limit. For instance, if the user may only place two more blocks on - * the workspace, an "a + b" block that has two shadow blocks would be disabled. - * @private - */ -Flyout.prototype.filterForCapacity_ = function() { - const blocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = blocks[i]); i++) { - if (this.permanentlyDisabled_.indexOf(block) === -1) { - const enable = this.targetWorkspace.isCapacityAvailable( - common.getBlockTypeCounts(block)); - while (block) { - block.setEnabled(enable); - block = block.getNextBlock(); + /** + * Set whether the flyout is visible. A value of true does not necessarily + * mean that the flyout is shown. It could be hidden because its container is + * hidden. + * @param {boolean} visible True if visible. + */ + setVisible(visible) { + const visibilityChanged = (visible !== this.isVisible()); + + this.isVisible_ = visible; + if (visibilityChanged) { + if (!this.autoClose) { + // Auto-close flyouts are ignored as drag targets, so only non + // auto-close flyouts need to have their drag target updated. + this.workspace_.recordDragTargets(); + } + this.updateDisplay_(); + } + } + + /** + * Set whether this flyout's container is visible. + * @param {boolean} visible Whether the container is visible. + */ + setContainerVisible(visible) { + const visibilityChanged = (visible !== this.containerVisible_); + this.containerVisible_ = visible; + if (visibilityChanged) { + this.updateDisplay_(); + } + } + + /** + * Update the display property of the flyout based whether it thinks it should + * be visible and whether its containing workspace is visible. + * @private + */ + updateDisplay_() { + let show = true; + if (!this.containerVisible_) { + show = false; + } else { + show = this.isVisible(); + } + this.svgGroup_.style.display = show ? 'block' : 'none'; + // Update the scrollbar's visibility too since it should mimic the + // flyout's visibility. + this.workspace_.scrollbar.setContainerVisible(show); + } + + /** + * Update the view based on coordinates calculated in position(). + * @param {number} width The computed width of the flyout's SVG group + * @param {number} height The computed height of the flyout's SVG group. + * @param {number} x The computed x origin of the flyout's SVG group. + * @param {number} y The computed y origin of the flyout's SVG group. + * @protected + */ + positionAt_(width, height, x, y) { + this.svgGroup_.setAttribute('width', width); + this.svgGroup_.setAttribute('height', height); + this.workspace_.setCachedParentSvgSize(width, height); + + if (this.svgGroup_.tagName === 'svg') { + const transform = 'translate(' + x + 'px,' + y + 'px)'; + dom.setCssTransform(this.svgGroup_, transform); + } else { + // IE and Edge don't support CSS transforms on SVG elements so + // it's important to set the transform on the SVG element itself + const transform = 'translate(' + x + ',' + y + ')'; + this.svgGroup_.setAttribute('transform', transform); + } + + // Update the scrollbar (if one exists). + const scrollbar = this.workspace_.scrollbar; + if (scrollbar) { + // Set the scrollbars origin to be the top left of the flyout. + scrollbar.setOrigin(x, y); + scrollbar.resize(); + // If origin changed and metrics haven't changed enough to trigger + // reposition in resize, we need to call setPosition. See issue #4692. + if (scrollbar.hScroll) { + scrollbar.hScroll.setPosition( + scrollbar.hScroll.position.x, scrollbar.hScroll.position.y); + } + if (scrollbar.vScroll) { + scrollbar.vScroll.setPosition( + scrollbar.vScroll.position.x, scrollbar.vScroll.position.y); } } } -}; -/** - * Reflow blocks and their mats. - */ -Flyout.prototype.reflow = function() { - if (this.reflowWrapper_) { - this.workspace_.removeChangeListener(this.reflowWrapper_); + /** + * Hide and empty the flyout. + */ + hide() { + if (!this.isVisible()) { + return; + } + this.setVisible(false); + // Delete all the event listeners. + for (let i = 0, listen; (listen = this.listeners_[i]); i++) { + browserEvents.unbind(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/ } - this.reflowInternal_(); - if (this.reflowWrapper_) { + + /** + * Show and populate the flyout. + * @param {!toolbox.FlyoutDefinition|string} flyoutDef Contents to display + * in the flyout. This is either an array of Nodes, a NodeList, a + * toolbox definition, or a string with the name of the dynamic category. + */ + show(flyoutDef) { + this.workspace_.setResizesEnabled(false); + this.hide(); + this.clearOldBlocks_(); + + // Handle dynamic categories, represented by a name instead of a list. + if (typeof flyoutDef === 'string') { + flyoutDef = this.getDynamicCategoryContents_(flyoutDef); + } + this.setVisible(true); + + // Parse the Array, Node or NodeList into a a list of flyout items. + const parsedContent = toolbox.convertFlyoutDefToJsonArray(flyoutDef); + const flyoutInfo = + /** @type {{contents:!Array, gaps:!Array}} */ ( + this.createFlyoutInfo_(parsedContent)); + + this.layout_(flyoutInfo.contents, flyoutInfo.gaps); + + // IE 11 is an incompetent browser that fails to fire mouseout events. + // When the mouse is over the background, deselect all blocks. + const deselectAll = + /** @this {Flyout} */ + function() { + const topBlocks = this.workspace_.getTopBlocks(false); + for (let i = 0, block; (block = topBlocks[i]); i++) { + block.removeSelect(); + } + }; + + this.listeners_.push(browserEvents.conditionalBind( + /** @type {!SVGPathElement} */ (this.svgBackground_), 'mouseover', this, + deselectAll)); + + if (this.horizontalLayout) { + this.height_ = 0; + } else { + this.width_ = 0; + } + this.workspace_.setResizesEnabled(true); + 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_); - } -}; - -/** - * @return {boolean} True if this flyout may be scrolled with a scrollbar or by - * dragging. - * @package - */ -Flyout.prototype.isScrollable = function() { - return this.workspace_.scrollbar ? this.workspace_.scrollbar.isVisible() : - false; -}; - -/** - * Copy a block from the flyout to the workspace and position it correctly. - * @param {!BlockSvg} oldBlock The flyout block to copy. - * @return {!BlockSvg} The new block in the main workspace. - * @private - */ -Flyout.prototype.placeNewBlock_ = function(oldBlock) { - const targetWorkspace = this.targetWorkspace; - const svgRootOld = oldBlock.getSvgRoot(); - if (!svgRootOld) { - throw Error('oldBlock is not rendered.'); + this.emptyRecycledBlocks_(); } - // Clone the block. - const json = /** @type {!blocks.State} */ (blocks.save(oldBlock)); - // Normallly this resizes leading to weird jumps. Save it for terminateDrag. - targetWorkspace.setResizesEnabled(false); - const block = /** @type {!BlockSvg} */ (blocks.append(json, targetWorkspace)); + /** + * Create the contents array and gaps array necessary to create the layout for + * the flyout. + * @param {!toolbox.FlyoutItemInfoArray} parsedContent The array + * of objects to show in the flyout. + * @return {{contents:Array, gaps:Array}} The list of contents + * and gaps needed to lay out the flyout. + * @private + */ + createFlyoutInfo_(parsedContent) { + const contents = []; + const gaps = []; + this.permanentlyDisabled_.length = 0; + const defaultGap = this.horizontalLayout ? this.GAP_X : this.GAP_Y; + for (let i = 0, contentInfo; (contentInfo = parsedContent[i]); i++) { + if (contentInfo['custom']) { + const customInfo = + /** @type {!toolbox.DynamicCategoryInfo} */ (contentInfo); + const categoryName = customInfo['custom']; + const flyoutDef = this.getDynamicCategoryContents_(categoryName); + const parsedDynamicContent = /** @type {!toolbox.FlyoutItemInfoArray} */ + (toolbox.convertFlyoutDefToJsonArray(flyoutDef)); + // Replace the element at i with the dynamic content it represents. + parsedContent.splice.apply( + parsedContent, [i, 1].concat(parsedDynamicContent)); + contentInfo = parsedContent[i]; + } - this.positionNewBlock_(oldBlock, block); + switch (contentInfo['kind'].toUpperCase()) { + case 'BLOCK': { + const blockInfo = /** @type {!toolbox.BlockInfo} */ (contentInfo); + const block = this.createFlyoutBlock_(blockInfo); + contents.push({type: 'block', block: block}); + this.addBlockGap_(blockInfo, gaps, defaultGap); + break; + } + case 'SEP': { + const sepInfo = /** @type {!toolbox.SeparatorInfo} */ (contentInfo); + this.addSeparatorGap_(sepInfo, gaps, defaultGap); + break; + } + case 'LABEL': { + const labelInfo = /** @type {!toolbox.LabelInfo} */ (contentInfo); + // A label is a button with different styling. + const label = this.createButton_(labelInfo, /** isLabel */ true); + contents.push({type: 'button', button: label}); + gaps.push(defaultGap); + break; + } + case 'BUTTON': { + const buttonInfo = /** @type {!toolbox.ButtonInfo} */ (contentInfo); + const button = this.createButton_(buttonInfo, /** isLabel */ false); + contents.push({type: 'button', button: button}); + gaps.push(defaultGap); + break; + } + } + } + return {contents: contents, gaps: gaps}; + } - return block; -}; + /** + * Gets the flyout definition for the dynamic category. + * @param {string} categoryName The name of the dynamic category. + * @return {!toolbox.FlyoutDefinition} The definition of the + * flyout in one of its many forms. + * @private + */ + getDynamicCategoryContents_(categoryName) { + // Look up the correct category generation function and call that to get a + // valid XML list. + const fnToApply = + this.workspace_.targetWorkspace.getToolboxCategoryCallback( + categoryName); + if (typeof fnToApply !== 'function') { + throw TypeError( + 'Couldn\'t find a callback function when opening' + + ' a toolbox category.'); + } + return fnToApply(this.workspace_.targetWorkspace); + } -/** - * Positions a block on the target workspace. - * @param {!BlockSvg} oldBlock The flyout block being copied. - * @param {!BlockSvg} block The block to posiiton. - * @private - */ -Flyout.prototype.positionNewBlock_ = function(oldBlock, block) { - const targetWorkspace = this.targetWorkspace; + /** + * Creates a flyout button or a flyout label. + * @param {!toolbox.ButtonOrLabelInfo} btnInfo + * The object holding information about a button or a label. + * @param {boolean} isLabel True if the button is a label, false otherwise. + * @return {!FlyoutButton} The object used to display the button in the + * flyout. + * @private + */ + createButton_(btnInfo, isLabel) { + const {FlyoutButton} = goog.module.get('Blockly.FlyoutButton'); + if (!FlyoutButton) { + throw Error('Missing require for Blockly.FlyoutButton'); + } + const curButton = new FlyoutButton( + this.workspace_, + /** @type {!WorkspaceSvg} */ (this.targetWorkspace), btnInfo, isLabel); + return curButton; + } - // The offset in pixels between the main workspace's origin and the upper left - // corner of the injection div. - const mainOffsetPixels = targetWorkspace.getOriginOffsetInPixels(); + /** + * Create a block from the xml and permanently disable any blocks that were + * defined as disabled. + * @param {!toolbox.BlockInfo} blockInfo The info of the block. + * @return {!BlockSvg} The block created from the blockInfo. + * @private + */ + createFlyoutBlock_(blockInfo) { + let block; + if (blockInfo['blockxml']) { + const xml = typeof blockInfo['blockxml'] === 'string' ? + Xml.textToDom(blockInfo['blockxml']) : + blockInfo['blockxml']; + block = this.getRecycledBlock_(xml.getAttribute('type')); + if (!block) { + block = Xml.domToBlock(xml, this.workspace_); + } + } else { + block = this.getRecycledBlock_(blockInfo['type']); + if (!block) { + if (blockInfo['enabled'] === undefined) { + blockInfo['enabled'] = blockInfo['disabled'] !== 'true' && + blockInfo['disabled'] !== true; + } + block = blocks.append( + /** @type {blocks.State} */ (blockInfo), this.workspace_); + } + } - // The offset in pixels between the flyout workspace's origin and the upper - // left corner of the injection div. - const flyoutOffsetPixels = this.workspace_.getOriginOffsetInPixels(); + if (!block.isEnabled()) { + // Record blocks that were initially disabled. + // Do not enable these blocks as a result of capacity filtering. + this.permanentlyDisabled_.push(block); + } + return /** @type {!BlockSvg} */ (block); + } - // The position of the old block in flyout workspace coordinates. - const oldBlockPos = oldBlock.getRelativeToSurfaceXY(); - // The position of the old block in pixels relative to the flyout - // workspace's origin. - oldBlockPos.scale(this.workspace_.scale); + /** + * Returns a block from the array of recycled blocks with the given type, or + * undefined if one cannot be found. + * @param {string} blockType The type of the block to try to recycle. + * @return {(!BlockSvg|undefined)} The recycled block, or undefined if + * one could not be recycled. + * @private + */ + getRecycledBlock_(blockType) { + let index = -1; + for (let i = 0; i < this.recycledBlocks_.length; i++) { + if (this.recycledBlocks_[i].type === blockType) { + index = i; + break; + } + } + return index === -1 ? undefined : this.recycledBlocks_.splice(index, 1)[0]; + } - // The position of the old block in pixels relative to the upper left corner - // of the injection div. - const oldBlockOffsetPixels = Coordinate.sum(flyoutOffsetPixels, oldBlockPos); + /** + * Adds a gap in the flyout based on block info. + * @param {!toolbox.BlockInfo} blockInfo Information about a block. + * @param {!Array} gaps The list of gaps between items in the flyout. + * @param {number} defaultGap The default gap between one element and the + * next. + * @private + */ + addBlockGap_(blockInfo, gaps, defaultGap) { + let gap; + if (blockInfo['gap']) { + gap = parseInt(blockInfo['gap'], 10); + } else if (blockInfo['blockxml']) { + const xml = typeof blockInfo['blockxml'] === 'string' ? + Xml.textToDom(blockInfo['blockxml']) : + blockInfo['blockxml']; + gap = parseInt(xml.getAttribute('gap'), 10); + } + gaps.push(isNaN(gap) ? defaultGap : gap); + } - // The position of the old block in pixels relative to the origin of the - // main workspace. - const finalOffset = - Coordinate.difference(oldBlockOffsetPixels, mainOffsetPixels); - // The position of the old block in main workspace coordinates. - finalOffset.scale(1 / targetWorkspace.scale); + /** + * Add the necessary gap in the flyout for a separator. + * @param {!toolbox.SeparatorInfo} sepInfo The object holding + * information about a separator. + * @param {!Array} gaps The list gaps between items in the flyout. + * @param {number} defaultGap The default gap between the button and next + * element. + * @private + */ + addSeparatorGap_(sepInfo, gaps, defaultGap) { + // Change the gap between two toolbox elements. + // + // The default gap is 24, can be set larger or smaller. + // This overwrites the gap attribute on the previous element. + const newGap = parseInt(sepInfo['gap'], 10); + // Ignore gaps before the first block. + if (!isNaN(newGap) && gaps.length > 0) { + gaps[gaps.length - 1] = newGap; + } else { + gaps.push(defaultGap); + } + } - block.moveTo(new Coordinate(finalOffset.x, finalOffset.y)); -}; + /** + * Delete blocks, mats and buttons from a previous showing of the flyout. + * @private + */ + clearOldBlocks_() { + // Delete any blocks from a previous showing. + const oldBlocks = this.workspace_.getTopBlocks(false); + for (let i = 0, block; (block = oldBlocks[i]); i++) { + if (this.blockIsRecyclable_(block)) { + this.recycleBlock_(block); + } else { + block.dispose(false, false); + } + } + // Delete any mats from a previous showing. + for (let j = 0; j < this.mats_.length; j++) { + const rect = this.mats_[j]; + if (rect) { + Tooltip.unbindMouseEvents(rect); + dom.removeNode(rect); + } + } + this.mats_.length = 0; + // Delete any buttons from a previous showing. + for (let i = 0, button; (button = this.buttons_[i]); i++) { + button.dispose(); + } + this.buttons_.length = 0; + + // Clear potential variables from the previous showing. + this.workspace_.getPotentialVariableMap().clear(); + } + + /** + * Empties all of the recycled blocks, properly disposing of them. + * @private + */ + emptyRecycledBlocks_() { + for (let i = 0; i < this.recycledBlocks_.length; i++) { + this.recycledBlocks_[i].dispose(); + } + this.recycledBlocks_ = []; + } + + /** + * Returns whether the given block can be recycled or not. + * @param {!BlockSvg} _block The block to check for recyclability. + * @return {boolean} True if the block can be recycled. False otherwise. + * @protected + */ + blockIsRecyclable_(_block) { + // By default, recycling is disabled. + return false; + } + + /** + * Puts a previously created block into the recycle bin and moves it to the + * top of the workspace. Used during large workspace swaps to limit the number + * of new DOM elements we need to create. + * @param {!BlockSvg} block The block to recycle. + * @private + */ + recycleBlock_(block) { + const xy = block.getRelativeToSurfaceXY(); + block.moveBy(-xy.x, -xy.y); + this.recycledBlocks_.push(block); + } + + /** + * Add listeners to a block that has been added to the flyout. + * @param {!SVGElement} root The root node of the SVG group the block is in. + * @param {!BlockSvg} block The block to add listeners for. + * @param {!SVGElement} rect The invisible rectangle under the block that acts + * as a mat for that block. + * @protected + */ + addBlockListeners_(root, block, rect) { + this.listeners_.push(browserEvents.conditionalBind( + root, 'mousedown', null, this.blockMouseDown_(block))); + this.listeners_.push(browserEvents.conditionalBind( + rect, 'mousedown', null, this.blockMouseDown_(block))); + this.listeners_.push( + browserEvents.bind(root, 'mouseenter', block, block.addSelect)); + this.listeners_.push( + browserEvents.bind(root, 'mouseleave', block, block.removeSelect)); + this.listeners_.push( + browserEvents.bind(rect, 'mouseenter', block, block.addSelect)); + this.listeners_.push( + browserEvents.bind(rect, 'mouseleave', block, block.removeSelect)); + } + + /** + * Handle a mouse-down on an SVG block in a non-closing flyout. + * @param {!BlockSvg} block The flyout block to copy. + * @return {!Function} Function to call when block is clicked. + * @private + */ + blockMouseDown_(block) { + const flyout = this; + return function(e) { + const gesture = flyout.targetWorkspace.getGesture(e); + if (gesture) { + gesture.setStartBlock(block); + gesture.handleFlyoutStart(e, flyout); + } + }; + } + + /** + * Mouse down on the flyout background. Start a vertical scroll drag. + * @param {!Event} e Mouse down event. + * @private + */ + onMouseDown_(e) { + const gesture = this.targetWorkspace.getGesture(e); + if (gesture) { + gesture.handleFlyoutStart(e, this); + } + } + + /** + * Does this flyout allow you to create a new instance of the given block? + * Used for deciding if a block can be "dragged out of" the flyout. + * @param {!BlockSvg} block The block to copy from the flyout. + * @return {boolean} True if you can create a new instance of the block, false + * otherwise. + * @package + */ + isBlockCreatable_(block) { + return block.isEnabled(); + } + + /** + * Create a copy of this block on the workspace. + * @param {!BlockSvg} originalBlock The block to copy from the flyout. + * @return {!BlockSvg} The newly created block. + * @throws {Error} if something went wrong with deserialization. + * @package + */ + createBlock(originalBlock) { + let newBlock = null; + eventUtils.disable(); + const variablesBeforeCreation = this.targetWorkspace.getAllVariables(); + this.targetWorkspace.setResizesEnabled(false); + try { + newBlock = this.placeNewBlock_(originalBlock); + } finally { + eventUtils.enable(); + } + + // Close the flyout. + this.targetWorkspace.hideChaff(); + + const newVariables = Variables.getAddedVariables( + this.targetWorkspace, variablesBeforeCreation); + + if (eventUtils.isEnabled()) { + eventUtils.setGroup(true); + // Fire a VarCreate event for each (if any) new variable created. + for (let i = 0; i < newVariables.length; i++) { + const thisVariable = newVariables[i]; + eventUtils.fire( + new (eventUtils.get(eventUtils.VAR_CREATE))(thisVariable)); + } + + // Block events come after var events, in case they refer to newly created + // variables. + eventUtils.fire(new (eventUtils.get(eventUtils.BLOCK_CREATE))(newBlock)); + } + if (this.autoClose) { + this.hide(); + } else { + this.filterForCapacity_(); + } + return newBlock; + } + + /** + * Initialize the given button: move it to the correct location, + * add listeners, etc. + * @param {!FlyoutButton} button The button to initialize and place. + * @param {number} x The x position of the cursor during this layout pass. + * @param {number} y The y position of the cursor during this layout pass. + * @protected + */ + initFlyoutButton_(button, x, y) { + const buttonSvg = button.createDom(); + button.moveTo(x, y); + button.show(); + // Clicking on a flyout button or label is a lot like clicking on the + // flyout background. + this.listeners_.push(browserEvents.conditionalBind( + buttonSvg, 'mousedown', this, this.onMouseDown_)); + + this.buttons_.push(button); + } + + /** + * Create and place a rectangle corresponding to the given block. + * @param {!BlockSvg} block The block to associate the rect to. + * @param {number} x The x position of the cursor during this layout pass. + * @param {number} y The y position of the cursor during this layout pass. + * @param {!{height: number, width: number}} blockHW The height and width of + * the block. + * @param {number} index The index into the mats list where this rect should + * be placed. + * @return {!SVGElement} Newly created SVG element for the rectangle behind + * the block. + * @protected + */ + createRect_(block, x, y, blockHW, index) { + // 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. + const rect = dom.createSvgElement( + Svg.RECT, { + 'fill-opacity': 0, + 'x': x, + 'y': y, + 'height': blockHW.height, + 'width': blockHW.width, + }, + null); + rect.tooltip = block; + 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.mats_[index] = rect; + return rect; + } + + /** + * Move a rectangle to sit exactly behind a block, taking into account tabs, + * hats, and any other protrusions we invent. + * @param {!SVGElement} rect The rectangle to move directly behind the block. + * @param {!BlockSvg} block The block the rectangle should be behind. + * @protected + */ + moveRectToBlock_(rect, block) { + const blockHW = block.getHeightWidth(); + rect.setAttribute('width', blockHW.width); + rect.setAttribute('height', blockHW.height); + + const blockXY = block.getRelativeToSurfaceXY(); + rect.setAttribute('y', blockXY.y); + rect.setAttribute('x', this.RTL ? blockXY.x - blockHW.width : blockXY.x); + } + + /** + * Filter the blocks on the flyout to disable the ones that are above the + * capacity limit. For instance, if the user may only place two more blocks + * on the workspace, an "a + b" block that has two shadow blocks would be + * disabled. + * @private + */ + filterForCapacity_() { + const blocks = this.workspace_.getTopBlocks(false); + for (let i = 0, block; (block = blocks[i]); i++) { + if (this.permanentlyDisabled_.indexOf(block) === -1) { + const enable = this.targetWorkspace.isCapacityAvailable( + common.getBlockTypeCounts(block)); + while (block) { + block.setEnabled(enable); + block = block.getNextBlock(); + } + } + } + } + + /** + * Reflow blocks and their mats. + */ + reflow() { + if (this.reflowWrapper_) { + this.workspace_.removeChangeListener(this.reflowWrapper_); + } + this.reflowInternal_(); + if (this.reflowWrapper_) { + this.workspace_.addChangeListener(this.reflowWrapper_); + } + } + + /** + * @return {boolean} True if this flyout may be scrolled with a scrollbar or + * by dragging. + * @package + */ + isScrollable() { + return this.workspace_.scrollbar ? this.workspace_.scrollbar.isVisible() : + false; + } + + /** + * Copy a block from the flyout to the workspace and position it correctly. + * @param {!BlockSvg} oldBlock The flyout block to copy. + * @return {!BlockSvg} The new block in the main workspace. + * @private + */ + placeNewBlock_(oldBlock) { + const targetWorkspace = this.targetWorkspace; + const svgRootOld = oldBlock.getSvgRoot(); + if (!svgRootOld) { + throw Error('oldBlock is not rendered.'); + } + + // Clone the block. + const json = /** @type {!blocks.State} */ (blocks.save(oldBlock)); + // Normallly this resizes leading to weird jumps. Save it for terminateDrag. + targetWorkspace.setResizesEnabled(false); + const block = + /** @type {!BlockSvg} */ (blocks.append(json, targetWorkspace)); + + this.positionNewBlock_(oldBlock, block); + + return block; + } + + /** + * Positions a block on the target workspace. + * @param {!BlockSvg} oldBlock The flyout block being copied. + * @param {!BlockSvg} block The block to posiiton. + * @private + */ + positionNewBlock_(oldBlock, block) { + const targetWorkspace = this.targetWorkspace; + + // The offset in pixels between the main workspace's origin and the upper + // left corner of the injection div. + const mainOffsetPixels = targetWorkspace.getOriginOffsetInPixels(); + + // The offset in pixels between the flyout workspace's origin and the upper + // left corner of the injection div. + const flyoutOffsetPixels = this.workspace_.getOriginOffsetInPixels(); + + // The position of the old block in flyout workspace coordinates. + const oldBlockPos = oldBlock.getRelativeToSurfaceXY(); + // The position of the old block in pixels relative to the flyout + // workspace's origin. + oldBlockPos.scale(this.workspace_.scale); + + // The position of the old block in pixels relative to the upper left corner + // of the injection div. + const oldBlockOffsetPixels = + Coordinate.sum(flyoutOffsetPixels, oldBlockPos); + + // The position of the old block in pixels relative to the origin of the + // main workspace. + const finalOffset = + Coordinate.difference(oldBlockOffsetPixels, mainOffsetPixels); + // The position of the old block in main workspace coordinates. + finalOffset.scale(1 / targetWorkspace.scale); + + block.moveTo(new Coordinate(finalOffset.x, finalOffset.y)); + } +} /** * Returns the bounding rectangle of the drag target area in pixel units diff --git a/core/flyout_button.js b/core/flyout_button.js index a76b0fa85..427f2b694 100644 --- a/core/flyout_button.js +++ b/core/flyout_button.js @@ -29,286 +29,303 @@ const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg'); /** - * Class for a button in the flyout. - * @param {!WorkspaceSvg} workspace The workspace in which to place this - * button. - * @param {!WorkspaceSvg} targetWorkspace The flyout's target workspace. - * @param {!toolbox.ButtonOrLabelInfo} json - * The JSON specifying the label/button. - * @param {boolean} isLabel Whether this button should be styled as a label. - * @constructor - * @package - * @alias Blockly.FlyoutButton + * Class for a button or label in the flyout. */ -const FlyoutButton = function(workspace, targetWorkspace, json, isLabel) { - // Labels behave the same as buttons, but are styled differently. +class FlyoutButton { + /** + * @param {!WorkspaceSvg} workspace The workspace in which to place this + * button. + * @param {!WorkspaceSvg} targetWorkspace The flyout's target workspace. + * @param {!toolbox.ButtonOrLabelInfo} json + * The JSON specifying the label/button. + * @param {boolean} isLabel Whether this button should be styled as a label. + * @package + * @alias Blockly.FlyoutButton + */ + constructor(workspace, targetWorkspace, json, isLabel) { + /** + * @type {!WorkspaceSvg} + * @private + */ + this.workspace_ = workspace; + + /** + * @type {!WorkspaceSvg} + * @private + */ + this.targetWorkspace_ = targetWorkspace; + + /** + * @type {string} + * @private + */ + this.text_ = json['text']; + + /** + * @type {!Coordinate} + * @private + */ + this.position_ = new Coordinate(0, 0); + + /** + * Whether this button should be styled as a label. + * Labels behave the same as buttons, but are styled differently. + * @type {boolean} + * @private + */ + this.isLabel_ = isLabel; + + /** + * The key to the function called when this button is clicked. + * @type {string} + * @private + */ + this.callbackKey_ = json['callbackKey'] || + /* Check the lower case version too to satisfy IE */ + json['callbackkey']; + + /** + * If specified, a CSS class to add to this button. + * @type {?string} + * @private + */ + this.cssClass_ = json['web-class'] || null; + + /** + * Mouse up event data. + * @type {?browserEvents.Data} + * @private + */ + this.onMouseUpWrapper_ = null; + + /** + * The JSON specifying the label / button. + * @type {!toolbox.ButtonOrLabelInfo} + */ + this.info = json; + + /** + * The width of the button's rect. + * @type {number} + */ + this.width = 0; + + /** + * The height of the button's rect. + * @type {number} + */ + this.height = 0; + + /** + * The root SVG group for the button or label. + * @type {?SVGGElement} + * @private + */ + this.svgGroup_ = null; + + /** + * The SVG element with the text of the label or button. + * @type {?SVGTextElement} + * @private + */ + this.svgText_ = null; + } /** - * @type {!WorkspaceSvg} + * Create the button elements. + * @return {!SVGElement} The button's SVG group. + */ + createDom() { + let cssClass = this.isLabel_ ? 'blocklyFlyoutLabel' : 'blocklyFlyoutButton'; + if (this.cssClass_) { + cssClass += ' ' + this.cssClass_; + } + + this.svgGroup_ = dom.createSvgElement( + Svg.G, {'class': cssClass}, this.workspace_.getCanvas()); + + let shadow; + if (!this.isLabel_) { + // Shadow rectangle (light source does not mirror in RTL). + shadow = dom.createSvgElement( + Svg.RECT, { + 'class': 'blocklyFlyoutButtonShadow', + 'rx': 4, + 'ry': 4, + 'x': 1, + 'y': 1, + }, + this.svgGroup_); + } + // Background rectangle. + const rect = dom.createSvgElement( + Svg.RECT, { + 'class': this.isLabel_ ? 'blocklyFlyoutLabelBackground' : + 'blocklyFlyoutButtonBackground', + 'rx': 4, + 'ry': 4, + }, + this.svgGroup_); + + const svgText = dom.createSvgElement( + Svg.TEXT, { + 'class': this.isLabel_ ? 'blocklyFlyoutLabelText' : 'blocklyText', + 'x': 0, + 'y': 0, + 'text-anchor': 'middle', + }, + this.svgGroup_); + let text = parsing.replaceMessageReferences(this.text_); + if (this.workspace_.RTL) { + // Force text to be RTL by adding an RLM. + text += '\u200F'; + } + svgText.textContent = text; + if (this.isLabel_) { + this.svgText_ = svgText; + this.workspace_.getThemeManager().subscribe( + this.svgText_, 'flyoutForegroundColour', 'fill'); + } + + const fontSize = style.getComputedStyle(svgText, 'fontSize'); + const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); + const fontFamily = style.getComputedStyle(svgText, 'fontFamily'); + this.width = dom.getFastTextWidthWithSizeString( + svgText, fontSize, fontWeight, fontFamily); + const fontMetrics = + dom.measureFontMetrics(text, fontSize, fontWeight, fontFamily); + this.height = fontMetrics.height; + + if (!this.isLabel_) { + this.width += 2 * FlyoutButton.TEXT_MARGIN_X; + this.height += 2 * FlyoutButton.TEXT_MARGIN_Y; + shadow.setAttribute('width', this.width); + shadow.setAttribute('height', this.height); + } + rect.setAttribute('width', this.width); + rect.setAttribute('height', this.height); + + svgText.setAttribute('x', this.width / 2); + svgText.setAttribute( + 'y', this.height / 2 - fontMetrics.height / 2 + fontMetrics.baseline); + + this.updateTransform_(); + + this.onMouseUpWrapper_ = browserEvents.conditionalBind( + this.svgGroup_, 'mouseup', this, this.onMouseUp_); + return this.svgGroup_; + } + + /** + * Correctly position the flyout button and make it visible. + */ + show() { + this.updateTransform_(); + this.svgGroup_.setAttribute('display', 'block'); + } + + /** + * Update SVG attributes to match internal state. * @private */ - this.workspace_ = workspace; + updateTransform_() { + this.svgGroup_.setAttribute( + 'transform', + 'translate(' + this.position_.x + ',' + this.position_.y + ')'); + } /** - * @type {!WorkspaceSvg} + * Move the button to the given x, y coordinates. + * @param {number} x The new x coordinate. + * @param {number} y The new y coordinate. + */ + moveTo(x, y) { + this.position_.x = x; + this.position_.y = y; + this.updateTransform_(); + } + + /** + * @return {boolean} Whether or not the button is a label. + */ + isLabel() { + return this.isLabel_; + } + + /** + * Location of the button. + * @return {!Coordinate} x, y coordinates. + * @package + */ + getPosition() { + return this.position_; + } + + /** + * @return {string} Text of the button. + */ + getButtonText() { + return this.text_; + } + + /** + * Get the button's target workspace. + * @return {!WorkspaceSvg} The target workspace of the flyout where this + * button resides. + */ + getTargetWorkspace() { + return this.targetWorkspace_; + } + + /** + * Dispose of this button. + */ + dispose() { + if (this.onMouseUpWrapper_) { + browserEvents.unbind(this.onMouseUpWrapper_); + } + if (this.svgGroup_) { + dom.removeNode(this.svgGroup_); + } + if (this.svgText_) { + this.workspace_.getThemeManager().unsubscribe(this.svgText_); + } + } + + /** + * Do something when the button is clicked. + * @param {!Event} e Mouse up event. * @private */ - this.targetWorkspace_ = targetWorkspace; + onMouseUp_(e) { + const gesture = this.targetWorkspace_.getGesture(e); + if (gesture) { + gesture.cancel(); + } - /** - * @type {string} - * @private - */ - this.text_ = json['text']; - - /** - * @type {!Coordinate} - * @private - */ - this.position_ = new Coordinate(0, 0); - - /** - * Whether this button should be styled as a label. - * @type {boolean} - * @private - */ - this.isLabel_ = isLabel; - - /** - * The key to the function called when this button is clicked. - * @type {string} - * @private - */ - this.callbackKey_ = json['callbackKey'] || - /* Check the lower case version too to satisfy IE */ - json['callbackkey']; - - /** - * If specified, a CSS class to add to this button. - * @type {?string} - * @private - */ - this.cssClass_ = json['web-class'] || null; - - /** - * Mouse up event data. - * @type {?browserEvents.Data} - * @private - */ - this.onMouseUpWrapper_ = null; - - /** - * The JSON specifying the label / button. - * @type {!toolbox.ButtonOrLabelInfo} - */ - this.info = json; - - /** - * The width of the button's rect. - * @type {number} - */ - this.width = 0; - - /** - * The height of the button's rect. - * @type {number} - */ - this.height = 0; -}; + if (this.isLabel_ && this.callbackKey_) { + console.warn( + 'Labels should not have callbacks. Label text: ' + this.text_); + } else if ( + !this.isLabel_ && + !(this.callbackKey_ && + this.targetWorkspace_.getButtonCallback(this.callbackKey_))) { + console.warn('Buttons should have callbacks. Button text: ' + this.text_); + } else if (!this.isLabel_) { + this.targetWorkspace_.getButtonCallback(this.callbackKey_)(this); + } + } +} /** * The horizontal margin around the text in the button. */ -FlyoutButton.MARGIN_X = 5; +FlyoutButton.TEXT_MARGIN_X = 5; /** * The vertical margin around the text in the button. */ -FlyoutButton.MARGIN_Y = 2; - -/** - * Create the button elements. - * @return {!SVGElement} The button's SVG group. - */ -FlyoutButton.prototype.createDom = function() { - let cssClass = this.isLabel_ ? 'blocklyFlyoutLabel' : 'blocklyFlyoutButton'; - if (this.cssClass_) { - cssClass += ' ' + this.cssClass_; - } - - this.svgGroup_ = dom.createSvgElement( - Svg.G, {'class': cssClass}, this.workspace_.getCanvas()); - - let shadow; - if (!this.isLabel_) { - // Shadow rectangle (light source does not mirror in RTL). - shadow = dom.createSvgElement( - Svg.RECT, { - 'class': 'blocklyFlyoutButtonShadow', - 'rx': 4, - 'ry': 4, - 'x': 1, - 'y': 1, - }, - this.svgGroup_); - } - // Background rectangle. - const rect = dom.createSvgElement( - Svg.RECT, { - 'class': this.isLabel_ ? 'blocklyFlyoutLabelBackground' : - 'blocklyFlyoutButtonBackground', - 'rx': 4, - 'ry': 4, - }, - this.svgGroup_); - - const svgText = dom.createSvgElement( - Svg.TEXT, { - 'class': this.isLabel_ ? 'blocklyFlyoutLabelText' : 'blocklyText', - 'x': 0, - 'y': 0, - 'text-anchor': 'middle', - }, - this.svgGroup_); - let text = parsing.replaceMessageReferences(this.text_); - if (this.workspace_.RTL) { - // Force text to be RTL by adding an RLM. - text += '\u200F'; - } - svgText.textContent = text; - if (this.isLabel_) { - this.svgText_ = svgText; - this.workspace_.getThemeManager().subscribe( - this.svgText_, 'flyoutForegroundColour', 'fill'); - } - - const fontSize = style.getComputedStyle(svgText, 'fontSize'); - const fontWeight = style.getComputedStyle(svgText, 'fontWeight'); - const fontFamily = style.getComputedStyle(svgText, 'fontFamily'); - this.width = dom.getFastTextWidthWithSizeString( - svgText, fontSize, fontWeight, fontFamily); - const fontMetrics = - dom.measureFontMetrics(text, fontSize, fontWeight, fontFamily); - this.height = fontMetrics.height; - - if (!this.isLabel_) { - this.width += 2 * FlyoutButton.MARGIN_X; - this.height += 2 * FlyoutButton.MARGIN_Y; - shadow.setAttribute('width', this.width); - shadow.setAttribute('height', this.height); - } - rect.setAttribute('width', this.width); - rect.setAttribute('height', this.height); - - svgText.setAttribute('x', this.width / 2); - svgText.setAttribute( - 'y', this.height / 2 - fontMetrics.height / 2 + fontMetrics.baseline); - - this.updateTransform_(); - - this.onMouseUpWrapper_ = browserEvents.conditionalBind( - this.svgGroup_, 'mouseup', this, this.onMouseUp_); - return this.svgGroup_; -}; - -/** - * Correctly position the flyout button and make it visible. - */ -FlyoutButton.prototype.show = function() { - this.updateTransform_(); - this.svgGroup_.setAttribute('display', 'block'); -}; - -/** - * Update SVG attributes to match internal state. - * @private - */ -FlyoutButton.prototype.updateTransform_ = function() { - this.svgGroup_.setAttribute( - 'transform', - 'translate(' + this.position_.x + ',' + this.position_.y + ')'); -}; - -/** - * Move the button to the given x, y coordinates. - * @param {number} x The new x coordinate. - * @param {number} y The new y coordinate. - */ -FlyoutButton.prototype.moveTo = function(x, y) { - this.position_.x = x; - this.position_.y = y; - this.updateTransform_(); -}; - -/** - * @return {boolean} Whether or not the button is a label. - */ -FlyoutButton.prototype.isLabel = function() { - return this.isLabel_; -}; - -/** - * Location of the button. - * @return {!Coordinate} x, y coordinates. - * @package - */ -FlyoutButton.prototype.getPosition = function() { - return this.position_; -}; - -/** - * @return {string} Text of the button. - */ -FlyoutButton.prototype.getButtonText = function() { - return this.text_; -}; - -/** - * Get the button's target workspace. - * @return {!WorkspaceSvg} The target workspace of the flyout where this - * button resides. - */ -FlyoutButton.prototype.getTargetWorkspace = function() { - return this.targetWorkspace_; -}; - -/** - * Dispose of this button. - */ -FlyoutButton.prototype.dispose = function() { - if (this.onMouseUpWrapper_) { - browserEvents.unbind(this.onMouseUpWrapper_); - } - if (this.svgGroup_) { - dom.removeNode(this.svgGroup_); - } - if (this.svgText_) { - this.workspace_.getThemeManager().unsubscribe(this.svgText_); - } -}; - -/** - * Do something when the button is clicked. - * @param {!Event} e Mouse up event. - * @private - */ -FlyoutButton.prototype.onMouseUp_ = function(e) { - const gesture = this.targetWorkspace_.getGesture(e); - if (gesture) { - gesture.cancel(); - } - - if (this.isLabel_ && this.callbackKey_) { - console.warn('Labels should not have callbacks. Label text: ' + this.text_); - } else if ( - !this.isLabel_ && - !(this.callbackKey_ && - this.targetWorkspace_.getButtonCallback(this.callbackKey_))) { - console.warn('Buttons should have callbacks. Button text: ' + this.text_); - } else if (!this.isLabel_) { - this.targetWorkspace_.getButtonCallback(this.callbackKey_)(this); - } -}; +FlyoutButton.TEXT_MARGIN_Y = 2; /** * CSS for buttons and labels. See css.js for use. diff --git a/core/flyout_horizontal.js b/core/flyout_horizontal.js index 1ff150c4f..e09ced7f9 100644 --- a/core/flyout_horizontal.js +++ b/core/flyout_horizontal.js @@ -17,7 +17,6 @@ goog.module('Blockly.HorizontalFlyout'); const WidgetDiv = goog.require('Blockly.WidgetDiv'); const browserEvents = goog.require('Blockly.browserEvents'); -const object = goog.require('Blockly.utils.object'); const registry = goog.require('Blockly.registry'); const toolbox = goog.require('Blockly.utils.toolbox'); /* eslint-disable-next-line no-unused-vars */ @@ -32,355 +31,358 @@ const {Scrollbar} = goog.require('Blockly.Scrollbar'); /** * Class for a flyout. - * @param {!Options} workspaceOptions Dictionary of options for the - * workspace. * @extends {Flyout} - * @constructor - * @alias Blockly.HorizontalFlyout */ -const HorizontalFlyout = function(workspaceOptions) { - HorizontalFlyout.superClass_.constructor.call(this, workspaceOptions); - this.horizontalLayout = true; -}; -object.inherits(HorizontalFlyout, Flyout); - -/** - * Sets the translation of the flyout to match the scrollbars. - * @param {!{x:number,y:number}} xyRatio Contains a y property which is a float - * between 0 and 1 specifying the degree of scrolling and a - * similar x property. - * @protected - */ -HorizontalFlyout.prototype.setMetrics_ = function(xyRatio) { - if (!this.isVisible()) { - return; +class HorizontalFlyout extends Flyout { + /** + * @param {!Options} workspaceOptions Dictionary of options for the + * workspace. + * @alias Blockly.HorizontalFlyout + */ + constructor(workspaceOptions) { + super(workspaceOptions); + this.horizontalLayout = true; } - const metricsManager = this.workspace_.getMetricsManager(); - const scrollMetrics = metricsManager.getScrollMetrics(); - const viewMetrics = metricsManager.getViewMetrics(); - const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + /** + * Sets the translation of the flyout to match the scrollbars. + * @param {!{x:number,y:number}} xyRatio Contains a y property which is a + * float between 0 and 1 specifying the degree of scrolling and a similar + * x property. + * @protected + */ + setMetrics_(xyRatio) { + if (!this.isVisible()) { + return; + } - if (typeof xyRatio.x === 'number') { - this.workspace_.scrollX = - -(scrollMetrics.left + - (scrollMetrics.width - viewMetrics.width) * xyRatio.x); + const metricsManager = this.workspace_.getMetricsManager(); + const scrollMetrics = metricsManager.getScrollMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + + if (typeof xyRatio.x === 'number') { + this.workspace_.scrollX = + -(scrollMetrics.left + + (scrollMetrics.width - viewMetrics.width) * xyRatio.x); + } + + this.workspace_.translate( + this.workspace_.scrollX + absoluteMetrics.left, + this.workspace_.scrollY + absoluteMetrics.top); } - this.workspace_.translate( - this.workspace_.scrollX + absoluteMetrics.left, - this.workspace_.scrollY + absoluteMetrics.top); -}; - -/** - * Calculates the x coordinate for the flyout position. - * @return {number} X coordinate. - */ -HorizontalFlyout.prototype.getX = function() { - // X is always 0 since this is a horizontal flyout. - return 0; -}; - -/** - * Calculates the y coordinate for the flyout position. - * @return {number} Y coordinate. - */ -HorizontalFlyout.prototype.getY = function() { - if (!this.isVisible()) { + /** + * Calculates the x coordinate for the flyout position. + * @return {number} X coordinate. + */ + getX() { + // X is always 0 since this is a horizontal flyout. return 0; } - const metricsManager = this.targetWorkspace.getMetricsManager(); - const absoluteMetrics = metricsManager.getAbsoluteMetrics(); - const viewMetrics = metricsManager.getViewMetrics(); - const toolboxMetrics = metricsManager.getToolboxMetrics(); - let y = 0; - const atTop = this.toolboxPosition_ === toolbox.Position.TOP; - // If this flyout is not the trashcan flyout (e.g. toolbox or mutator). - if (this.targetWorkspace.toolboxPosition === this.toolboxPosition_) { - // If there is a category toolbox. - if (this.targetWorkspace.getToolbox()) { - if (atTop) { - y = toolboxMetrics.height; + /** + * Calculates the y coordinate for the flyout position. + * @return {number} Y coordinate. + */ + getY() { + if (!this.isVisible()) { + return 0; + } + const metricsManager = this.targetWorkspace.getMetricsManager(); + const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const toolboxMetrics = metricsManager.getToolboxMetrics(); + + let y = 0; + const atTop = this.toolboxPosition_ === toolbox.Position.TOP; + // If this flyout is not the trashcan flyout (e.g. toolbox or mutator). + if (this.targetWorkspace.toolboxPosition === this.toolboxPosition_) { + // If there is a category toolbox. + if (this.targetWorkspace.getToolbox()) { + if (atTop) { + y = toolboxMetrics.height; + } else { + y = viewMetrics.height - this.height_; + } + // Simple (flyout-only) toolbox. } else { - y = viewMetrics.height - this.height_; + if (atTop) { + y = 0; + } else { + // The simple flyout does not cover the workspace. + y = viewMetrics.height; + } } - // Simple (flyout-only) toolbox. + // Trashcan flyout is opposite the main flyout. } else { if (atTop) { y = 0; } else { - // The simple flyout does not cover the workspace. - y = viewMetrics.height; + // Because the anchor point of the flyout is on the top, but we want + // to align the bottom edge of the flyout with the bottom edge of the + // blocklyDiv, we calculate the full height of the div minus the height + // of the flyout. + y = viewMetrics.height + absoluteMetrics.top - this.height_; } } - // Trashcan flyout is opposite the main flyout. - } else { + + return y; + } + + /** + * Move the flyout to the edge of the workspace. + */ + position() { + if (!this.isVisible() || !this.targetWorkspace.isVisible()) { + return; + } + const metricsManager = this.targetWorkspace.getMetricsManager(); + const targetWorkspaceViewMetrics = metricsManager.getViewMetrics(); + + // Record the width for workspace metrics. + this.width_ = targetWorkspaceViewMetrics.width; + + const edgeWidth = targetWorkspaceViewMetrics.width - 2 * this.CORNER_RADIUS; + const edgeHeight = this.height_ - this.CORNER_RADIUS; + this.setBackgroundPath_(edgeWidth, edgeHeight); + + const x = this.getX(); + const y = this.getY(); + + this.positionAt_(this.width_, this.height_, x, y); + } + + /** + * 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 + */ + setBackgroundPath_(width, height) { + const atTop = this.toolboxPosition_ === toolbox.Position.TOP; + // Start at top left. + const path = ['M 0,' + (atTop ? 0 : this.CORNER_RADIUS)]; + if (atTop) { - y = 0; + // Top. + path.push('h', width + 2 * 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', -width); + // Left. + path.push( + 'a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, + -this.CORNER_RADIUS, -this.CORNER_RADIUS); + path.push('z'); } else { - // Because the anchor point of the flyout is on the top, but we want - // to align the bottom edge of the flyout with the bottom edge of the - // blocklyDiv, we calculate the full height of the div minus the height - // of the flyout. - y = viewMetrics.height + absoluteMetrics.top - this.height_; + // Top. + path.push( + 'a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, + this.CORNER_RADIUS, -this.CORNER_RADIUS); + path.push('h', width); + // Right. + path.push( + 'a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, + this.CORNER_RADIUS, this.CORNER_RADIUS); + path.push('v', height); + // Bottom. + path.push('h', -width - 2 * this.CORNER_RADIUS); + // Left. + path.push('z'); + } + this.svgBackground_.setAttribute('d', path.join(' ')); + } + + /** + * Scroll the flyout to the top. + */ + scrollToStart() { + this.workspace_.scrollbar.setX(this.RTL ? Infinity : 0); + } + + /** + * Scroll the flyout. + * @param {!Event} e Mouse wheel scroll event. + * @protected + */ + wheel_(e) { + const scrollDelta = browserEvents.getScrollDeltaPixels(e); + const delta = scrollDelta.x || scrollDelta.y; + + if (delta) { + const metricsManager = this.workspace_.getMetricsManager(); + const scrollMetrics = metricsManager.getScrollMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + + const pos = (viewMetrics.left - scrollMetrics.left) + delta; + this.workspace_.scrollbar.setX(pos); + // When the flyout moves from a wheel event, hide WidgetDiv and + // DropDownDiv. + WidgetDiv.hide(); + DropDownDiv.hideWithoutAnimation(); + } + + // Don't scroll the page. + e.preventDefault(); + // Don't propagate mousewheel event (zooming). + e.stopPropagation(); + } + + /** + * Lay out the blocks in the flyout. + * @param {!Array} contents The blocks and buttons to lay out. + * @param {!Array} gaps The visible gaps between blocks. + * @protected + */ + layout_(contents, gaps) { + this.workspace_.scale = this.targetWorkspace.scale; + const margin = this.MARGIN; + let cursorX = margin + this.tabWidth_; + const cursorY = margin; + if (this.RTL) { + contents = contents.reverse(); + } + + for (let i = 0, item; (item = contents[i]); i++) { + if (item.type === 'block') { + const block = item.block; + const allBlocks = block.getDescendants(false); + for (let 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(); + const root = block.getSvgRoot(); + const blockHW = block.getHeightWidth(); + + // Figure out where to place the block. + const tab = block.outputConnection ? this.tabWidth_ : 0; + let moveX; + if (this.RTL) { + moveX = cursorX + blockHW.width; + } else { + moveX = cursorX - tab; + } + block.moveBy(moveX, cursorY); + + const rect = this.createRect_(block, moveX, cursorY, blockHW, i); + cursorX += (blockHW.width + gaps[i]); + + this.addBlockListeners_(root, block, rect); + } else if (item.type === 'button') { + this.initFlyoutButton_(item.button, cursorX, cursorY); + cursorX += (item.button.width + gaps[i]); + } } } - return y; -}; + /** + * 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 {!Coordinate} currentDragDeltaXY How far the pointer has + * moved from the position at mouse down, in pixel units. + * @return {boolean} True if the drag is toward the workspace. + * @package + */ + isDragTowardWorkspace(currentDragDeltaXY) { + const dx = currentDragDeltaXY.x; + const dy = currentDragDeltaXY.y; + // Direction goes from -180 to 180, with 0 toward the right and 90 on top. + const dragDirection = Math.atan2(dy, dx) / Math.PI * 180; -/** - * Move the flyout to the edge of the workspace. - */ -HorizontalFlyout.prototype.position = function() { - if (!this.isVisible() || !this.targetWorkspace.isVisible()) { - return; - } - const metricsManager = this.targetWorkspace.getMetricsManager(); - const targetWorkspaceViewMetrics = metricsManager.getViewMetrics(); - - // Record the width for workspace metrics. - this.width_ = targetWorkspaceViewMetrics.width; - - const edgeWidth = targetWorkspaceViewMetrics.width - 2 * this.CORNER_RADIUS; - const edgeHeight = this.height_ - this.CORNER_RADIUS; - this.setBackgroundPath_(edgeWidth, edgeHeight); - - const x = this.getX(); - const y = this.getY(); - - this.positionAt_(this.width_, this.height_, x, y); -}; - -/** - * 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 - */ -HorizontalFlyout.prototype.setBackgroundPath_ = function(width, height) { - const atTop = this.toolboxPosition_ === toolbox.Position.TOP; - // Start at top left. - const path = ['M 0,' + (atTop ? 0 : this.CORNER_RADIUS)]; - - if (atTop) { - // Top. - path.push('h', width + 2 * 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', -width); - // 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); - // Right. - path.push( - 'a', this.CORNER_RADIUS, this.CORNER_RADIUS, 0, 0, 1, - this.CORNER_RADIUS, this.CORNER_RADIUS); - path.push('v', height); - // Bottom. - path.push('h', -width - 2 * this.CORNER_RADIUS); - // Left. - path.push('z'); - } - this.svgBackground_.setAttribute('d', path.join(' ')); -}; - -/** - * Scroll the flyout to the top. - */ -HorizontalFlyout.prototype.scrollToStart = function() { - this.workspace_.scrollbar.setX(this.RTL ? Infinity : 0); -}; - -/** - * Scroll the flyout. - * @param {!Event} e Mouse wheel scroll event. - * @protected - */ -HorizontalFlyout.prototype.wheel_ = function(e) { - const scrollDelta = browserEvents.getScrollDeltaPixels(e); - const delta = scrollDelta.x || scrollDelta.y; - - if (delta) { - const metricsManager = this.workspace_.getMetricsManager(); - const scrollMetrics = metricsManager.getScrollMetrics(); - const viewMetrics = metricsManager.getViewMetrics(); - - const pos = (viewMetrics.left - scrollMetrics.left) + delta; - this.workspace_.scrollbar.setX(pos); - // When the flyout moves from a wheel event, hide WidgetDiv and DropDownDiv. - WidgetDiv.hide(); - DropDownDiv.hideWithoutAnimation(); + const range = this.dragAngleRange_; + // Check for up or down dragging. + if ((dragDirection < 90 + range && dragDirection > 90 - range) || + (dragDirection > -90 - range && dragDirection < -90 + range)) { + return true; + } + return false; } - // Don't scroll the page. - e.preventDefault(); - // Don't propagate mousewheel event (zooming). - e.stopPropagation(); -}; + /** + * Returns the bounding rectangle of the drag target area in pixel units + * relative to viewport. + * @return {?Rect} The component's bounding box. Null if drag + * target area should be ignored. + */ + getClientRect() { + if (!this.svgGroup_ || this.autoClose || !this.isVisible()) { + // The bounding rectangle won't compute correctly if the flyout is closed + // and auto-close flyouts aren't valid drag targets (or delete areas). + return null; + } -/** - * Lay out the blocks in the flyout. - * @param {!Array} contents The blocks and buttons to lay out. - * @param {!Array} gaps The visible gaps between blocks. - * @protected - */ -HorizontalFlyout.prototype.layout_ = function(contents, gaps) { - this.workspace_.scale = this.targetWorkspace.scale; - const margin = this.MARGIN; - let cursorX = margin + this.tabWidth_; - const cursorY = margin; - if (this.RTL) { - contents = contents.reverse(); - } + const 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). + const BIG_NUM = 1000000000; + const top = flyoutRect.top; - for (let i = 0, item; (item = contents[i]); i++) { - if (item.type === 'block') { - const block = item.block; - const allBlocks = block.getDescendants(false); - for (let 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(); - const root = block.getSvgRoot(); - const blockHW = block.getHeightWidth(); - - // Figure out where to place the block. - const tab = block.outputConnection ? this.tabWidth_ : 0; - let moveX; - if (this.RTL) { - moveX = cursorX + blockHW.width; - } else { - moveX = cursorX - tab; - } - block.moveBy(moveX, cursorY); - - const rect = this.createRect_(block, moveX, cursorY, blockHW, i); - cursorX += (blockHW.width + gaps[i]); - - this.addBlockListeners_(root, block, rect); - } else if (item.type === 'button') { - this.initFlyoutButton_(item.button, cursorX, cursorY); - cursorX += (item.button.width + gaps[i]); + if (this.toolboxPosition_ === toolbox.Position.TOP) { + const height = flyoutRect.height; + return new Rect(-BIG_NUM, top + height, -BIG_NUM, BIG_NUM); + } else { // Bottom. + return new Rect(top, BIG_NUM, -BIG_NUM, BIG_NUM); } } -}; -/** - * 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 {!Coordinate} currentDragDeltaXY How far the pointer has - * moved from the position at mouse down, in pixel units. - * @return {boolean} True if the drag is toward the workspace. - * @package - */ -HorizontalFlyout.prototype.isDragTowardWorkspace = function( - currentDragDeltaXY) { - const dx = currentDragDeltaXY.x; - const dy = currentDragDeltaXY.y; - // Direction goes from -180 to 180, with 0 toward the right and 90 on top. - const dragDirection = Math.atan2(dy, dx) / Math.PI * 180; - - const range = this.dragAngleRange_; - // Check for up or down dragging. - if ((dragDirection < 90 + range && dragDirection > 90 - range) || - (dragDirection > -90 - range && dragDirection < -90 + range)) { - return true; - } - return false; -}; - -/** - * Returns the bounding rectangle of the drag target area in pixel units - * relative to viewport. - * @return {?Rect} The component's bounding box. Null if drag - * target area should be ignored. - */ -HorizontalFlyout.prototype.getClientRect = function() { - if (!this.svgGroup_ || this.autoClose || !this.isVisible()) { - // The bounding rectangle won't compute correctly if the flyout is closed - // and auto-close flyouts aren't valid drag targets (or delete areas). - return null; - } - - const 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). - const BIG_NUM = 1000000000; - const top = flyoutRect.top; - - if (this.toolboxPosition_ === toolbox.Position.TOP) { - const height = flyoutRect.height; - return new Rect(-BIG_NUM, top + height, -BIG_NUM, BIG_NUM); - } else { // Bottom. - return new Rect(top, BIG_NUM, -BIG_NUM, BIG_NUM); - } -}; - -/** - * Compute height of flyout. toolbox.Position mat under each block. - * For RTL: Lay out the blocks right-aligned. - * @protected - */ -HorizontalFlyout.prototype.reflowInternal_ = function() { - this.workspace_.scale = this.getFlyoutScale(); - let flyoutHeight = 0; - const blocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = blocks[i]); i++) { - flyoutHeight = Math.max(flyoutHeight, block.getHeightWidth().height); - } - const buttons = this.buttons_; - for (let i = 0, button; (button = buttons[i]); i++) { - flyoutHeight = Math.max(flyoutHeight, button.height); - } - flyoutHeight += this.MARGIN * 1.5; - flyoutHeight *= this.workspace_.scale; - flyoutHeight += Scrollbar.scrollbarThickness; - - if (this.height_ !== flyoutHeight) { + /** + * Compute height of flyout. toolbox.Position mat under each block. + * For RTL: Lay out the blocks right-aligned. + * @protected + */ + reflowInternal_() { + this.workspace_.scale = this.getFlyoutScale(); + let flyoutHeight = 0; + const blocks = this.workspace_.getTopBlocks(false); for (let i = 0, block; (block = blocks[i]); i++) { - if (block.flyoutRect_) { - this.moveRectToBlock_(block.flyoutRect_, block); + flyoutHeight = Math.max(flyoutHeight, block.getHeightWidth().height); + } + const buttons = this.buttons_; + for (let i = 0, button; (button = buttons[i]); i++) { + flyoutHeight = Math.max(flyoutHeight, button.height); + } + flyoutHeight += this.MARGIN * 1.5; + flyoutHeight *= this.workspace_.scale; + flyoutHeight += Scrollbar.scrollbarThickness; + + if (this.height_ !== flyoutHeight) { + for (let i = 0, block; (block = blocks[i]); i++) { + if (block.flyoutRect_) { + this.moveRectToBlock_(block.flyoutRect_, block); + } } - } - if (this.targetWorkspace.toolboxPosition === this.toolboxPosition_ && - this.toolboxPosition_ === toolbox.Position.TOP && - !this.targetWorkspace.getToolbox()) { - // This flyout is a simple toolbox. Reposition the workspace so that (0,0) - // is in the correct position relative to the new absolute edge (ie - // toolbox edge). - this.targetWorkspace.translate( - this.targetWorkspace.scrollX, - this.targetWorkspace.scrollY + flyoutHeight); - } + if (this.targetWorkspace.toolboxPosition === this.toolboxPosition_ && + this.toolboxPosition_ === toolbox.Position.TOP && + !this.targetWorkspace.getToolbox()) { + // This flyout is a simple toolbox. Reposition the workspace so that + // (0,0) is in the correct position relative to the new absolute edge + // (ie toolbox edge). + this.targetWorkspace.translate( + this.targetWorkspace.scrollX, + this.targetWorkspace.scrollY + flyoutHeight); + } - // Record the height for workspace metrics and .position. - this.height_ = flyoutHeight; - this.position(); - this.targetWorkspace.recordDragTargets(); + // Record the height for workspace metrics and .position. + this.height_ = flyoutHeight; + this.position(); + this.targetWorkspace.recordDragTargets(); + } } -}; +} registry.register( registry.Type.FLYOUTS_HORIZONTAL_TOOLBOX, registry.DEFAULT, diff --git a/core/flyout_metrics_manager.js b/core/flyout_metrics_manager.js index ddbabd2a6..ec01db1b3 100644 --- a/core/flyout_metrics_manager.js +++ b/core/flyout_metrics_manager.js @@ -15,7 +15,6 @@ */ goog.module('Blockly.FlyoutMetricsManager'); -const object = goog.require('Blockly.utils.object'); /* eslint-disable-next-line no-unused-vars */ const {IFlyout} = goog.requireType('Blockly.IFlyout'); const {MetricsManager} = goog.require('Blockly.MetricsManager'); @@ -26,81 +25,82 @@ const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg'); /** * Calculates metrics for a flyout's workspace. * The metrics are mainly used to size scrollbars for the flyout. - * @param {!WorkspaceSvg} workspace The flyout's workspace. - * @param {!IFlyout} flyout The flyout. * @extends {MetricsManager} - * @constructor - * @alias Blockly.FlyoutMetricsManager */ -const FlyoutMetricsManager = function(workspace, flyout) { +class FlyoutMetricsManager extends MetricsManager { /** - * The flyout that owns the workspace to calculate metrics for. - * @type {!IFlyout} - * @protected + * @param {!WorkspaceSvg} workspace The flyout's workspace. + * @param {!IFlyout} flyout The flyout. + * @alias Blockly.FlyoutMetricsManager */ - this.flyout_ = flyout; + constructor(workspace, flyout) { + super(workspace); - FlyoutMetricsManager.superClass_.constructor.call(this, workspace); -}; -object.inherits(FlyoutMetricsManager, MetricsManager); - -/** - * Gets the bounding box of the blocks on the flyout's workspace. - * This is in workspace coordinates. - * @return {!SVGRect|{height: number, y: number, width: number, x: number}} The - * bounding box of the blocks on the workspace. - * @private - */ -FlyoutMetricsManager.prototype.getBoundingBox_ = function() { - let blockBoundingBox; - try { - blockBoundingBox = this.workspace_.getCanvas().getBBox(); - } catch (e) { - // Firefox has trouble with hidden elements (Bug 528969). - // 2021 Update: It looks like this was fixed around Firefox 77 released in - // 2020. - blockBoundingBox = {height: 0, y: 0, width: 0, x: 0}; + /** + * The flyout that owns the workspace to calculate metrics for. + * @type {!IFlyout} + * @protected + */ + this.flyout_ = flyout; } - return blockBoundingBox; -}; -/** - * @override - */ -FlyoutMetricsManager.prototype.getContentMetrics = function( - opt_getWorkspaceCoordinates) { - // The bounding box is in workspace coordinates. - const blockBoundingBox = this.getBoundingBox_(); - const scale = opt_getWorkspaceCoordinates ? 1 : this.workspace_.scale; + /** + * Gets the bounding box of the blocks on the flyout's workspace. + * This is in workspace coordinates. + * @return {!SVGRect|{height: number, y: number, width: number, x: number}} + * The bounding box of the blocks on the workspace. + * @private + */ + getBoundingBox_() { + let blockBoundingBox; + try { + blockBoundingBox = this.workspace_.getCanvas().getBBox(); + } catch (e) { + // Firefox has trouble with hidden elements (Bug 528969). + // 2021 Update: It looks like this was fixed around Firefox 77 released in + // 2020. + blockBoundingBox = {height: 0, y: 0, width: 0, x: 0}; + } + return blockBoundingBox; + } - return { - height: blockBoundingBox.height * scale, - width: blockBoundingBox.width * scale, - top: blockBoundingBox.y * scale, - left: blockBoundingBox.x * scale, - }; -}; + /** + * @override + */ + getContentMetrics(opt_getWorkspaceCoordinates) { + // The bounding box is in workspace coordinates. + const blockBoundingBox = this.getBoundingBox_(); + const scale = opt_getWorkspaceCoordinates ? 1 : this.workspace_.scale; -/** - * @override - */ -FlyoutMetricsManager.prototype.getScrollMetrics = function( - opt_getWorkspaceCoordinates, opt_viewMetrics, opt_contentMetrics) { - const contentMetrics = opt_contentMetrics || this.getContentMetrics(); - const margin = this.flyout_.MARGIN * this.workspace_.scale; - const scale = opt_getWorkspaceCoordinates ? this.workspace_.scale : 1; + return { + height: blockBoundingBox.height * scale, + width: blockBoundingBox.width * scale, + top: blockBoundingBox.y * scale, + left: blockBoundingBox.x * scale, + }; + } - // The left padding isn't just the margin. Some blocks are also offset by - // tabWidth so that value and statement blocks line up. - // The contentMetrics.left value is equivalent to the variable left padding. - const leftPadding = contentMetrics.left; + /** + * @override + */ + getScrollMetrics( + opt_getWorkspaceCoordinates, opt_viewMetrics, opt_contentMetrics) { + const contentMetrics = opt_contentMetrics || this.getContentMetrics(); + const margin = this.flyout_.MARGIN * this.workspace_.scale; + const scale = opt_getWorkspaceCoordinates ? this.workspace_.scale : 1; - return { - height: (contentMetrics.height + 2 * margin) / scale, - width: (contentMetrics.width + leftPadding + margin) / scale, - top: 0, - left: 0, - }; -}; + // The left padding isn't just the margin. Some blocks are also offset by + // tabWidth so that value and statement blocks line up. + // The contentMetrics.left value is equivalent to the variable left padding. + const leftPadding = contentMetrics.left; + + return { + height: (contentMetrics.height + 2 * margin) / scale, + width: (contentMetrics.width + leftPadding + margin) / scale, + top: 0, + left: 0, + }; + } +} exports.FlyoutMetricsManager = FlyoutMetricsManager; diff --git a/core/flyout_vertical.js b/core/flyout_vertical.js index b6e985f99..546124eeb 100644 --- a/core/flyout_vertical.js +++ b/core/flyout_vertical.js @@ -17,7 +17,6 @@ goog.module('Blockly.VerticalFlyout'); const WidgetDiv = goog.require('Blockly.WidgetDiv'); const browserEvents = goog.require('Blockly.browserEvents'); -const object = goog.require('Blockly.utils.object'); const registry = goog.require('Blockly.registry'); const toolbox = goog.require('Blockly.utils.toolbox'); /* eslint-disable-next-line no-unused-vars */ @@ -36,16 +35,353 @@ goog.require('Blockly.constants'); /** * Class for a flyout. - * @param {!Options} workspaceOptions Dictionary of options for the - * workspace. * @extends {Flyout} - * @constructor - * @alias Blockly.VerticalFlyout */ -const VerticalFlyout = function(workspaceOptions) { - VerticalFlyout.superClass_.constructor.call(this, workspaceOptions); -}; -object.inherits(VerticalFlyout, Flyout); +class VerticalFlyout extends Flyout { + /** + * @param {!Options} workspaceOptions Dictionary of options for the + * workspace. + * @alias Blockly.VerticalFlyout + */ + constructor(workspaceOptions) { + super(workspaceOptions); + } + + /** + * Sets the translation of the flyout to match the scrollbars. + * @param {!{x:number,y:number}} xyRatio Contains a y property which is a + * float between 0 and 1 specifying the degree of scrolling and a similar + * x property. + * @protected + */ + setMetrics_(xyRatio) { + if (!this.isVisible()) { + return; + } + const metricsManager = this.workspace_.getMetricsManager(); + const scrollMetrics = metricsManager.getScrollMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + + if (typeof xyRatio.y === 'number') { + this.workspace_.scrollY = + -(scrollMetrics.top + + (scrollMetrics.height - viewMetrics.height) * xyRatio.y); + } + this.workspace_.translate( + this.workspace_.scrollX + absoluteMetrics.left, + this.workspace_.scrollY + absoluteMetrics.top); + } + + /** + * Calculates the x coordinate for the flyout position. + * @return {number} X coordinate. + */ + getX() { + if (!this.isVisible()) { + return 0; + } + const metricsManager = this.targetWorkspace.getMetricsManager(); + const absoluteMetrics = metricsManager.getAbsoluteMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const toolboxMetrics = metricsManager.getToolboxMetrics(); + let x = 0; + + // If this flyout is not the trashcan flyout (e.g. toolbox or mutator). + if (this.targetWorkspace.toolboxPosition === this.toolboxPosition_) { + // If there is a category toolbox. + if (this.targetWorkspace.getToolbox()) { + if (this.toolboxPosition_ === toolbox.Position.LEFT) { + x = toolboxMetrics.width; + } else { + x = viewMetrics.width - this.width_; + } + // Simple (flyout-only) toolbox. + } else { + if (this.toolboxPosition_ === toolbox.Position.LEFT) { + x = 0; + } else { + // The simple flyout does not cover the workspace. + x = viewMetrics.width; + } + } + // Trashcan flyout is opposite the main flyout. + } else { + if (this.toolboxPosition_ === toolbox.Position.LEFT) { + x = 0; + } else { + // Because the anchor point of the flyout is on the left, but we want + // to align the right edge of the flyout with the right edge of the + // blocklyDiv, we calculate the full width of the div minus the width + // of the flyout. + x = viewMetrics.width + absoluteMetrics.left - this.width_; + } + } + + return x; + } + + /** + * Calculates the y coordinate for the flyout position. + * @return {number} Y coordinate. + */ + getY() { + // Y is always 0 since this is a vertical flyout. + return 0; + } + + /** + * Move the flyout to the edge of the workspace. + */ + position() { + if (!this.isVisible() || !this.targetWorkspace.isVisible()) { + return; + } + const metricsManager = this.targetWorkspace.getMetricsManager(); + const targetWorkspaceViewMetrics = metricsManager.getViewMetrics(); + + // Record the height for workspace metrics. + this.height_ = targetWorkspaceViewMetrics.height; + + const edgeWidth = this.width_ - this.CORNER_RADIUS; + const edgeHeight = + targetWorkspaceViewMetrics.height - 2 * this.CORNER_RADIUS; + this.setBackgroundPath_(edgeWidth, edgeHeight); + + const x = this.getX(); + const y = this.getY(); + + this.positionAt_(this.width_, this.height_, x, y); + } + + /** + * 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 + */ + setBackgroundPath_(width, height) { + const atRight = this.toolboxPosition_ === toolbox.Position.RIGHT; + const totalWidth = width + this.CORNER_RADIUS; + + // Decide whether to start on the left or right. + const path = ['M ' + (atRight ? totalWidth : 0) + ',0']; + // Top. + path.push('h', atRight ? -width : 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)); + // 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', atRight ? width : -width); + path.push('z'); + this.svgBackground_.setAttribute('d', path.join(' ')); + } + + /** + * Scroll the flyout to the top. + */ + scrollToStart() { + this.workspace_.scrollbar.setY(0); + } + + /** + * Scroll the flyout. + * @param {!Event} e Mouse wheel scroll event. + * @protected + */ + wheel_(e) { + const scrollDelta = browserEvents.getScrollDeltaPixels(e); + + if (scrollDelta.y) { + const metricsManager = this.workspace_.getMetricsManager(); + const scrollMetrics = metricsManager.getScrollMetrics(); + const viewMetrics = metricsManager.getViewMetrics(); + const pos = (viewMetrics.top - scrollMetrics.top) + scrollDelta.y; + + this.workspace_.scrollbar.setY(pos); + // When the flyout moves from a wheel event, hide WidgetDiv and + // DropDownDiv. + WidgetDiv.hide(); + DropDownDiv.hideWithoutAnimation(); + } + + // Don't scroll the page. + e.preventDefault(); + // Don't propagate mousewheel event (zooming). + e.stopPropagation(); + } + + /** + * Lay out the blocks in the flyout. + * @param {!Array} contents The blocks and buttons to lay out. + * @param {!Array} gaps The visible gaps between blocks. + * @protected + */ + layout_(contents, gaps) { + this.workspace_.scale = this.targetWorkspace.scale; + const margin = this.MARGIN; + const cursorX = this.RTL ? margin : margin + this.tabWidth_; + let cursorY = margin; + + for (let i = 0, item; (item = contents[i]); i++) { + if (item.type === 'block') { + const block = item.block; + const allBlocks = block.getDescendants(false); + for (let 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(); + const root = block.getSvgRoot(); + const blockHW = block.getHeightWidth(); + const moveX = + block.outputConnection ? cursorX - this.tabWidth_ : cursorX; + block.moveBy(moveX, cursorY); + + const rect = this.createRect_( + block, this.RTL ? moveX - blockHW.width : moveX, cursorY, blockHW, + i); + + this.addBlockListeners_(root, block, rect); + + cursorY += blockHW.height + gaps[i]; + } else if (item.type === 'button') { + this.initFlyoutButton_(item.button, cursorX, cursorY); + cursorY += item.button.height + gaps[i]; + } + } + } + + /** + * 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 {!Coordinate} currentDragDeltaXY How far the pointer has + * moved from the position at mouse down, in pixel units. + * @return {boolean} True if the drag is toward the workspace. + * @package + */ + isDragTowardWorkspace(currentDragDeltaXY) { + const dx = currentDragDeltaXY.x; + const dy = currentDragDeltaXY.y; + // Direction goes from -180 to 180, with 0 toward the right and 90 on top. + const dragDirection = Math.atan2(dy, dx) / Math.PI * 180; + + const range = this.dragAngleRange_; + // Check for left or right dragging. + if ((dragDirection < range && dragDirection > -range) || + (dragDirection < -180 + range || dragDirection > 180 - range)) { + return true; + } + return false; + } + + /** + * Returns the bounding rectangle of the drag target area in pixel units + * relative to viewport. + * @return {?Rect} The component's bounding box. Null if drag + * target area should be ignored. + */ + getClientRect() { + if (!this.svgGroup_ || this.autoClose || !this.isVisible()) { + // The bounding rectangle won't compute correctly if the flyout is closed + // and auto-close flyouts aren't valid drag targets (or delete areas). + return null; + } + + const 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). + const BIG_NUM = 1000000000; + const left = flyoutRect.left; + + if (this.toolboxPosition_ === toolbox.Position.LEFT) { + const width = flyoutRect.width; + return new Rect(-BIG_NUM, BIG_NUM, -BIG_NUM, left + width); + } else { // Right + return new Rect(-BIG_NUM, BIG_NUM, left, BIG_NUM); + } + } + + /** + * Compute width of flyout. toolbox.Position mat under each block. + * For RTL: Lay out the blocks and buttons to be right-aligned. + * @protected + */ + reflowInternal_() { + this.workspace_.scale = this.getFlyoutScale(); + let flyoutWidth = 0; + const blocks = this.workspace_.getTopBlocks(false); + for (let i = 0, block; (block = blocks[i]); i++) { + let width = block.getHeightWidth().width; + if (block.outputConnection) { + width -= this.tabWidth_; + } + flyoutWidth = Math.max(flyoutWidth, width); + } + for (let i = 0, button; (button = this.buttons_[i]); i++) { + flyoutWidth = Math.max(flyoutWidth, button.width); + } + flyoutWidth += this.MARGIN * 1.5 + this.tabWidth_; + flyoutWidth *= this.workspace_.scale; + flyoutWidth += Scrollbar.scrollbarThickness; + + if (this.width_ !== flyoutWidth) { + for (let i = 0, block; (block = blocks[i]); i++) { + if (this.RTL) { + // With the flyoutWidth known, right-align the blocks. + const oldX = block.getRelativeToSurfaceXY().x; + let newX = flyoutWidth / this.workspace_.scale - this.MARGIN; + if (!block.outputConnection) { + newX -= this.tabWidth_; + } + block.moveBy(newX - oldX, 0); + } + if (block.flyoutRect_) { + this.moveRectToBlock_(block.flyoutRect_, block); + } + } + if (this.RTL) { + // With the flyoutWidth known, right-align the buttons. + for (let i = 0, button; (button = this.buttons_[i]); i++) { + const y = button.getPosition().y; + const x = flyoutWidth / this.workspace_.scale - button.width - + this.MARGIN - this.tabWidth_; + button.moveTo(x, y); + } + } + + if (this.targetWorkspace.toolboxPosition === this.toolboxPosition_ && + this.toolboxPosition_ === toolbox.Position.LEFT && + !this.targetWorkspace.getToolbox()) { + // This flyout is a simple toolbox. Reposition the workspace so that + // (0,0) is in the correct position relative to the new absolute edge + // (ie toolbox edge). + this.targetWorkspace.translate( + this.targetWorkspace.scrollX + flyoutWidth, + this.targetWorkspace.scrollY); + } + + // Record the width for workspace metrics and .position. + this.width_ = flyoutWidth; + this.position(); + this.targetWorkspace.recordDragTargets(); + } + } +} /** * The name of the vertical flyout in the registry. @@ -53,336 +389,6 @@ object.inherits(VerticalFlyout, Flyout); */ VerticalFlyout.registryName = 'verticalFlyout'; -/** - * Sets the translation of the flyout to match the scrollbars. - * @param {!{x:number,y:number}} xyRatio Contains a y property which is a float - * between 0 and 1 specifying the degree of scrolling and a - * similar x property. - * @protected - */ -VerticalFlyout.prototype.setMetrics_ = function(xyRatio) { - if (!this.isVisible()) { - return; - } - const metricsManager = this.workspace_.getMetricsManager(); - const scrollMetrics = metricsManager.getScrollMetrics(); - const viewMetrics = metricsManager.getViewMetrics(); - const absoluteMetrics = metricsManager.getAbsoluteMetrics(); - - if (typeof xyRatio.y === 'number') { - this.workspace_.scrollY = - -(scrollMetrics.top + - (scrollMetrics.height - viewMetrics.height) * xyRatio.y); - } - this.workspace_.translate( - this.workspace_.scrollX + absoluteMetrics.left, - this.workspace_.scrollY + absoluteMetrics.top); -}; - -/** - * Calculates the x coordinate for the flyout position. - * @return {number} X coordinate. - */ -VerticalFlyout.prototype.getX = function() { - if (!this.isVisible()) { - return 0; - } - const metricsManager = this.targetWorkspace.getMetricsManager(); - const absoluteMetrics = metricsManager.getAbsoluteMetrics(); - const viewMetrics = metricsManager.getViewMetrics(); - const toolboxMetrics = metricsManager.getToolboxMetrics(); - let x = 0; - - // If this flyout is not the trashcan flyout (e.g. toolbox or mutator). - if (this.targetWorkspace.toolboxPosition === this.toolboxPosition_) { - // If there is a category toolbox. - if (this.targetWorkspace.getToolbox()) { - if (this.toolboxPosition_ === toolbox.Position.LEFT) { - x = toolboxMetrics.width; - } else { - x = viewMetrics.width - this.width_; - } - // Simple (flyout-only) toolbox. - } else { - if (this.toolboxPosition_ === toolbox.Position.LEFT) { - x = 0; - } else { - // The simple flyout does not cover the workspace. - x = viewMetrics.width; - } - } - // Trashcan flyout is opposite the main flyout. - } else { - if (this.toolboxPosition_ === toolbox.Position.LEFT) { - x = 0; - } else { - // Because the anchor point of the flyout is on the left, but we want - // to align the right edge of the flyout with the right edge of the - // blocklyDiv, we calculate the full width of the div minus the width - // of the flyout. - x = viewMetrics.width + absoluteMetrics.left - this.width_; - } - } - - return x; -}; - -/** - * Calculates the y coordinate for the flyout position. - * @return {number} Y coordinate. - */ -VerticalFlyout.prototype.getY = function() { - // Y is always 0 since this is a vertical flyout. - return 0; -}; - -/** - * Move the flyout to the edge of the workspace. - */ -VerticalFlyout.prototype.position = function() { - if (!this.isVisible() || !this.targetWorkspace.isVisible()) { - return; - } - const metricsManager = this.targetWorkspace.getMetricsManager(); - const targetWorkspaceViewMetrics = metricsManager.getViewMetrics(); - - // Record the height for workspace metrics. - this.height_ = targetWorkspaceViewMetrics.height; - - const edgeWidth = this.width_ - this.CORNER_RADIUS; - const edgeHeight = targetWorkspaceViewMetrics.height - 2 * this.CORNER_RADIUS; - this.setBackgroundPath_(edgeWidth, edgeHeight); - - const x = this.getX(); - const y = this.getY(); - - this.positionAt_(this.width_, this.height_, x, y); -}; - -/** - * 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 - */ -VerticalFlyout.prototype.setBackgroundPath_ = function(width, height) { - const atRight = this.toolboxPosition_ === toolbox.Position.RIGHT; - const totalWidth = width + this.CORNER_RADIUS; - - // Decide whether to start on the left or right. - const path = ['M ' + (atRight ? totalWidth : 0) + ',0']; - // Top. - path.push('h', atRight ? -width : 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)); - // 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', atRight ? width : -width); - path.push('z'); - this.svgBackground_.setAttribute('d', path.join(' ')); -}; - -/** - * Scroll the flyout to the top. - */ -VerticalFlyout.prototype.scrollToStart = function() { - this.workspace_.scrollbar.setY(0); -}; - -/** - * Scroll the flyout. - * @param {!Event} e Mouse wheel scroll event. - * @protected - */ -VerticalFlyout.prototype.wheel_ = function(e) { - const scrollDelta = browserEvents.getScrollDeltaPixels(e); - - if (scrollDelta.y) { - const metricsManager = this.workspace_.getMetricsManager(); - const scrollMetrics = metricsManager.getScrollMetrics(); - const viewMetrics = metricsManager.getViewMetrics(); - const pos = (viewMetrics.top - scrollMetrics.top) + scrollDelta.y; - - this.workspace_.scrollbar.setY(pos); - // When the flyout moves from a wheel event, hide WidgetDiv and DropDownDiv. - WidgetDiv.hide(); - DropDownDiv.hideWithoutAnimation(); - } - - // Don't scroll the page. - e.preventDefault(); - // Don't propagate mousewheel event (zooming). - e.stopPropagation(); -}; - -/** - * Lay out the blocks in the flyout. - * @param {!Array} contents The blocks and buttons to lay out. - * @param {!Array} gaps The visible gaps between blocks. - * @protected - */ -VerticalFlyout.prototype.layout_ = function(contents, gaps) { - this.workspace_.scale = this.targetWorkspace.scale; - const margin = this.MARGIN; - const cursorX = this.RTL ? margin : margin + this.tabWidth_; - let cursorY = margin; - - for (let i = 0, item; (item = contents[i]); i++) { - if (item.type === 'block') { - const block = item.block; - const allBlocks = block.getDescendants(false); - for (let 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(); - const root = block.getSvgRoot(); - const blockHW = block.getHeightWidth(); - const moveX = block.outputConnection ? cursorX - this.tabWidth_ : cursorX; - block.moveBy(moveX, cursorY); - - const rect = this.createRect_( - block, this.RTL ? moveX - blockHW.width : moveX, cursorY, blockHW, i); - - this.addBlockListeners_(root, block, rect); - - cursorY += blockHW.height + gaps[i]; - } else if (item.type === 'button') { - this.initFlyoutButton_(item.button, cursorX, cursorY); - cursorY += item.button.height + gaps[i]; - } - } -}; - -/** - * 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 {!Coordinate} currentDragDeltaXY How far the pointer has - * moved from the position at mouse down, in pixel units. - * @return {boolean} True if the drag is toward the workspace. - * @package - */ -VerticalFlyout.prototype.isDragTowardWorkspace = function(currentDragDeltaXY) { - const dx = currentDragDeltaXY.x; - const dy = currentDragDeltaXY.y; - // Direction goes from -180 to 180, with 0 toward the right and 90 on top. - const dragDirection = Math.atan2(dy, dx) / Math.PI * 180; - - const range = this.dragAngleRange_; - // Check for left or right dragging. - if ((dragDirection < range && dragDirection > -range) || - (dragDirection < -180 + range || dragDirection > 180 - range)) { - return true; - } - return false; -}; - -/** - * Returns the bounding rectangle of the drag target area in pixel units - * relative to viewport. - * @return {?Rect} The component's bounding box. Null if drag - * target area should be ignored. - */ -VerticalFlyout.prototype.getClientRect = function() { - if (!this.svgGroup_ || this.autoClose || !this.isVisible()) { - // The bounding rectangle won't compute correctly if the flyout is closed - // and auto-close flyouts aren't valid drag targets (or delete areas). - return null; - } - - const 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). - const BIG_NUM = 1000000000; - const left = flyoutRect.left; - - if (this.toolboxPosition_ === toolbox.Position.LEFT) { - const width = flyoutRect.width; - return new Rect(-BIG_NUM, BIG_NUM, -BIG_NUM, left + width); - } else { // Right - return new Rect(-BIG_NUM, BIG_NUM, left, BIG_NUM); - } -}; - -/** - * Compute width of flyout. toolbox.Position mat under each block. - * For RTL: Lay out the blocks and buttons to be right-aligned. - * @protected - */ -VerticalFlyout.prototype.reflowInternal_ = function() { - this.workspace_.scale = this.getFlyoutScale(); - let flyoutWidth = 0; - const blocks = this.workspace_.getTopBlocks(false); - for (let i = 0, block; (block = blocks[i]); i++) { - let width = block.getHeightWidth().width; - if (block.outputConnection) { - width -= this.tabWidth_; - } - flyoutWidth = Math.max(flyoutWidth, width); - } - for (let i = 0, button; (button = this.buttons_[i]); i++) { - flyoutWidth = Math.max(flyoutWidth, button.width); - } - flyoutWidth += this.MARGIN * 1.5 + this.tabWidth_; - flyoutWidth *= this.workspace_.scale; - flyoutWidth += Scrollbar.scrollbarThickness; - - if (this.width_ !== flyoutWidth) { - for (let i = 0, block; (block = blocks[i]); i++) { - if (this.RTL) { - // With the flyoutWidth known, right-align the blocks. - const oldX = block.getRelativeToSurfaceXY().x; - let newX = flyoutWidth / this.workspace_.scale - this.MARGIN; - if (!block.outputConnection) { - newX -= this.tabWidth_; - } - block.moveBy(newX - oldX, 0); - } - if (block.flyoutRect_) { - this.moveRectToBlock_(block.flyoutRect_, block); - } - } - if (this.RTL) { - // With the flyoutWidth known, right-align the buttons. - for (let i = 0, button; (button = this.buttons_[i]); i++) { - const y = button.getPosition().y; - const x = flyoutWidth / this.workspace_.scale - button.width - - this.MARGIN - this.tabWidth_; - button.moveTo(x, y); - } - } - - if (this.targetWorkspace.toolboxPosition === this.toolboxPosition_ && - this.toolboxPosition_ === toolbox.Position.LEFT && - !this.targetWorkspace.getToolbox()) { - // This flyout is a simple toolbox. Reposition the workspace so that (0,0) - // is in the correct position relative to the new absolute edge (ie - // toolbox edge). - this.targetWorkspace.translate( - this.targetWorkspace.scrollX + flyoutWidth, - this.targetWorkspace.scrollY); - } - - // Record the width for workspace metrics and .position. - this.width_ = flyoutWidth; - this.position(); - this.targetWorkspace.recordDragTargets(); - } -}; - registry.register( registry.Type.FLYOUTS_VERTICAL_TOOLBOX, registry.DEFAULT, VerticalFlyout); diff --git a/core/gesture.js b/core/gesture.js index faa178076..c0f6df674 100644 --- a/core/gesture.js +++ b/core/gesture.js @@ -55,956 +55,968 @@ goog.require('Blockly.Events.Click'); /** * Class for one gesture. - * @param {!Event} e The event that kicked off this gesture. - * @param {!WorkspaceSvg} creatorWorkspace The workspace that created - * this gesture and has a reference to it. - * @constructor - * @alias Blockly.Gesture */ -const Gesture = function(e, creatorWorkspace) { +class Gesture { /** - * The position of the mouse when the gesture started. Units are CSS pixels, - * with (0, 0) at the top left of the browser window (mouseEvent clientX/Y). - * @type {Coordinate} - * @private + * @param {!Event} e The event that kicked off this gesture. + * @param {!WorkspaceSvg} creatorWorkspace The workspace that created + * this gesture and has a reference to it. + * @alias Blockly.Gesture */ - this.mouseDownXY_ = null; + constructor(e, creatorWorkspace) { + /** + * The position of the mouse when the gesture started. Units are CSS + * pixels, with (0, 0) at the top left of the browser window (mouseEvent + * clientX/Y). + * @type {Coordinate} + * @private + */ + this.mouseDownXY_ = null; - /** - * How far the mouse has moved during this drag, in pixel units. - * (0, 0) is at this.mouseDownXY_. - * @type {!Coordinate} - * @private - */ - this.currentDragDeltaXY_ = new Coordinate(0, 0); + /** + * How far the mouse has moved during this drag, in pixel units. + * (0, 0) is at this.mouseDownXY_. + * @type {!Coordinate} + * @private + */ + this.currentDragDeltaXY_ = new Coordinate(0, 0); - /** - * The bubble that the gesture started on, or null if it did not start on a - * bubble. - * @type {IBubble} - * @private - */ - this.startBubble_ = null; + /** + * The bubble that the gesture started on, or null if it did not start on a + * bubble. + * @type {IBubble} + * @private + */ + this.startBubble_ = null; - /** - * The field that the gesture started on, or null if it did not start on a - * field. - * @type {Field} - * @private - */ - this.startField_ = null; + /** + * The field that the gesture started on, or null if it did not start on a + * field. + * @type {Field} + * @private + */ + this.startField_ = null; - /** - * The block that the gesture started on, or null if it did not start on a - * block. - * @type {BlockSvg} - * @private - */ - this.startBlock_ = null; - - /** - * The block that this gesture targets. If the gesture started on a - * shadow block, this is the first non-shadow parent of the block. If the - * gesture started in the flyout, this is the root block of the block group - * that was clicked or dragged. - * @type {BlockSvg} - * @private - */ - this.targetBlock_ = null; - - /** - * The workspace that the gesture started on. There may be multiple - * workspaces on a page; this is more accurate than using - * Blockly.common.getMainWorkspace(). - * @type {WorkspaceSvg} - * @protected - */ - this.startWorkspace_ = null; - - /** - * The workspace that created this gesture. This workspace keeps a reference - * to the gesture, which will need to be cleared at deletion. - * This may be different from the start workspace. For instance, a flyout is - * a workspace, but its parent workspace manages gestures for it. - * @type {!WorkspaceSvg} - * @private - */ - this.creatorWorkspace_ = creatorWorkspace; - - /** - * Whether the pointer has at any point moved out of the drag radius. - * A gesture that exceeds the drag radius is a drag even if it ends exactly - * at its start point. - * @type {boolean} - * @private - */ - this.hasExceededDragRadius_ = false; - - /** - * Whether the workspace is currently being dragged. - * @type {boolean} - * @private - */ - this.isDraggingWorkspace_ = false; - - /** - * Whether the block is currently being dragged. - * @type {boolean} - * @private - */ - this.isDraggingBlock_ = false; - - /** - * Whether the bubble is currently being dragged. - * @type {boolean} - * @private - */ - this.isDraggingBubble_ = false; - - /** - * The event that most recently updated this gesture. - * @type {!Event} - * @private - */ - this.mostRecentEvent_ = e; - - /** - * A handle to use to unbind a mouse move listener at the end of a drag. - * Opaque data returned from Blockly.bindEventWithChecks_. - * @type {?browserEvents.Data} - * @protected - */ - this.onMoveWrapper_ = null; - - /** - * A handle to use to unbind a mouse up listener at the end of a drag. - * Opaque data returned from Blockly.bindEventWithChecks_. - * @type {?browserEvents.Data} - * @protected - */ - this.onUpWrapper_ = null; - - /** - * The object tracking a bubble drag, or null if none is in progress. - * @type {BubbleDragger} - * @private - */ - this.bubbleDragger_ = null; - - /** - * The object tracking a block drag, or null if none is in progress. - * @type {?IBlockDragger} - * @private - */ - this.blockDragger_ = null; - - /** - * The object tracking a workspace or flyout workspace drag, or null if none - * is in progress. - * @type {WorkspaceDragger} - * @private - */ - this.workspaceDragger_ = null; - - /** - * The flyout a gesture started in, if any. - * @type {IFlyout} - * @private - */ - this.flyout_ = null; - - /** - * Boolean for sanity-checking that some code is only called once. - * @type {boolean} - * @private - */ - this.calledUpdateIsDragging_ = false; - - /** - * Boolean for sanity-checking that some code is only called once. - * @type {boolean} - * @private - */ - this.hasStarted_ = false; - - /** - * Boolean used internally to break a cycle in disposal. - * @type {boolean} - * @protected - */ - this.isEnding_ = false; - - /** - * Boolean used to indicate whether or not to heal the stack after - * disconnecting a block. - * @type {boolean} - * @private - */ - this.healStack_ = !internalConstants.DRAG_STACK; -}; - -/** - * Sever all links from this object. - * @package - */ -Gesture.prototype.dispose = function() { - Touch.clearTouchIdentifier(); - Tooltip.unblock(); - // Clear the owner's reference to this gesture. - this.creatorWorkspace_.clearGesture(); - - if (this.onMoveWrapper_) { - browserEvents.unbind(this.onMoveWrapper_); - } - if (this.onUpWrapper_) { - browserEvents.unbind(this.onUpWrapper_); - } - - if (this.blockDragger_) { - this.blockDragger_.dispose(); - } - if (this.workspaceDragger_) { - this.workspaceDragger_.dispose(); - } - if (this.bubbleDragger_) { - this.bubbleDragger_.dispose(); - } -}; - -/** - * Update internal state based on an event. - * @param {!Event} e The most recent mouse or touch event. - * @private - */ -Gesture.prototype.updateFromEvent_ = function(e) { - const currentXY = new Coordinate(e.clientX, e.clientY); - const changed = this.updateDragDelta_(currentXY); - // Exceeded the drag radius for the first time. - if (changed) { - this.updateIsDragging_(); - Touch.longStop(); - } - this.mostRecentEvent_ = e; -}; - -/** - * DO MATH to set currentDragDeltaXY_ based on the most recent mouse position. - * @param {!Coordinate} currentXY The most recent mouse/pointer - * position, in pixel units, with (0, 0) at the window's top left corner. - * @return {boolean} True if the drag just exceeded the drag radius for the - * first time. - * @private - */ -Gesture.prototype.updateDragDelta_ = function(currentXY) { - this.currentDragDeltaXY_ = Coordinate.difference( - currentXY, - /** @type {!Coordinate} */ (this.mouseDownXY_)); - - if (!this.hasExceededDragRadius_) { - const currentDragDelta = Coordinate.magnitude(this.currentDragDeltaXY_); - - // The flyout has a different drag radius from the rest of Blockly. - const limitRadius = this.flyout_ ? internalConstants.FLYOUT_DRAG_RADIUS : - internalConstants.DRAG_RADIUS; - - this.hasExceededDragRadius_ = currentDragDelta > limitRadius; - return this.hasExceededDragRadius_; - } - return false; -}; - -/** - * Update this gesture to record whether a block is being dragged from the - * flyout. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * If a block should be dragged from the flyout this function creates the new - * block on the main workspace and updates targetBlock_ and startWorkspace_. - * @return {boolean} True if a block is being dragged from the flyout. - * @private - */ -Gesture.prototype.updateIsDraggingFromFlyout_ = function() { - if (!this.targetBlock_) { - return false; - } - if (!this.flyout_.isBlockCreatable_(this.targetBlock_)) { - return false; - } - if (!this.flyout_.isScrollable() || - this.flyout_.isDragTowardWorkspace(this.currentDragDeltaXY_)) { - this.startWorkspace_ = this.flyout_.targetWorkspace; - this.startWorkspace_.updateScreenCalculationsIfScrolled(); - // Start the event group now, so that the same event group is used for block - // creation and block dragging. - if (!eventUtils.getGroup()) { - eventUtils.setGroup(true); - } - // The start block is no longer relevant, because this is a drag. + /** + * The block that the gesture started on, or null if it did not start on a + * block. + * @type {BlockSvg} + * @private + */ this.startBlock_ = null; - this.targetBlock_ = this.flyout_.createBlock(this.targetBlock_); - this.targetBlock_.select(); - return true; - } - return false; -}; -/** - * Update this gesture to record whether a bubble is being dragged. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * If a bubble should be dragged this function creates the necessary - * BubbleDragger and starts the drag. - * @return {boolean} True if a bubble is being dragged. - * @private - */ -Gesture.prototype.updateIsDraggingBubble_ = function() { - if (!this.startBubble_) { + /** + * The block that this gesture targets. If the gesture started on a + * shadow block, this is the first non-shadow parent of the block. If the + * gesture started in the flyout, this is the root block of the block group + * that was clicked or dragged. + * @type {BlockSvg} + * @private + */ + this.targetBlock_ = null; + + /** + * The workspace that the gesture started on. There may be multiple + * workspaces on a page; this is more accurate than using + * Blockly.common.getMainWorkspace(). + * @type {WorkspaceSvg} + * @protected + */ + this.startWorkspace_ = null; + + /** + * The workspace that created this gesture. This workspace keeps a + * reference to the gesture, which will need to be cleared at deletion. This + * may be different from the start workspace. For instance, a flyout is a + * workspace, but its parent workspace manages gestures for it. + * @type {!WorkspaceSvg} + * @private + */ + this.creatorWorkspace_ = creatorWorkspace; + + /** + * Whether the pointer has at any point moved out of the drag radius. + * A gesture that exceeds the drag radius is a drag even if it ends exactly + * at its start point. + * @type {boolean} + * @private + */ + this.hasExceededDragRadius_ = false; + + /** + * Whether the workspace is currently being dragged. + * @type {boolean} + * @private + */ + this.isDraggingWorkspace_ = false; + + /** + * Whether the block is currently being dragged. + * @type {boolean} + * @private + */ + this.isDraggingBlock_ = false; + + /** + * Whether the bubble is currently being dragged. + * @type {boolean} + * @private + */ + this.isDraggingBubble_ = false; + + /** + * The event that most recently updated this gesture. + * @type {!Event} + * @private + */ + this.mostRecentEvent_ = e; + + /** + * A handle to use to unbind a mouse move listener at the end of a drag. + * Opaque data returned from Blockly.bindEventWithChecks_. + * @type {?browserEvents.Data} + * @protected + */ + this.onMoveWrapper_ = null; + + /** + * A handle to use to unbind a mouse up listener at the end of a drag. + * Opaque data returned from Blockly.bindEventWithChecks_. + * @type {?browserEvents.Data} + * @protected + */ + this.onUpWrapper_ = null; + + /** + * The object tracking a bubble drag, or null if none is in progress. + * @type {BubbleDragger} + * @private + */ + this.bubbleDragger_ = null; + + /** + * The object tracking a block drag, or null if none is in progress. + * @type {?IBlockDragger} + * @private + */ + this.blockDragger_ = null; + + /** + * The object tracking a workspace or flyout workspace drag, or null if none + * is in progress. + * @type {WorkspaceDragger} + * @private + */ + this.workspaceDragger_ = null; + + /** + * The flyout a gesture started in, if any. + * @type {IFlyout} + * @private + */ + this.flyout_ = null; + + /** + * Boolean for sanity-checking that some code is only called once. + * @type {boolean} + * @private + */ + this.calledUpdateIsDragging_ = false; + + /** + * Boolean for sanity-checking that some code is only called once. + * @type {boolean} + * @private + */ + this.hasStarted_ = false; + + /** + * Boolean used internally to break a cycle in disposal. + * @type {boolean} + * @protected + */ + this.isEnding_ = false; + + /** + * Boolean used to indicate whether or not to heal the stack after + * disconnecting a block. + * @type {boolean} + * @private + */ + this.healStack_ = !internalConstants.DRAG_STACK; + } + + /** + * Sever all links from this object. + * @package + */ + dispose() { + Touch.clearTouchIdentifier(); + Tooltip.unblock(); + // Clear the owner's reference to this gesture. + this.creatorWorkspace_.clearGesture(); + + if (this.onMoveWrapper_) { + browserEvents.unbind(this.onMoveWrapper_); + } + if (this.onUpWrapper_) { + browserEvents.unbind(this.onUpWrapper_); + } + + if (this.blockDragger_) { + this.blockDragger_.dispose(); + } + if (this.workspaceDragger_) { + this.workspaceDragger_.dispose(); + } + if (this.bubbleDragger_) { + this.bubbleDragger_.dispose(); + } + } + + /** + * Update internal state based on an event. + * @param {!Event} e The most recent mouse or touch event. + * @private + */ + updateFromEvent_(e) { + const currentXY = new Coordinate(e.clientX, e.clientY); + const changed = this.updateDragDelta_(currentXY); + // Exceeded the drag radius for the first time. + if (changed) { + this.updateIsDragging_(); + Touch.longStop(); + } + this.mostRecentEvent_ = e; + } + + /** + * DO MATH to set currentDragDeltaXY_ based on the most recent mouse position. + * @param {!Coordinate} currentXY The most recent mouse/pointer + * position, in pixel units, with (0, 0) at the window's top left corner. + * @return {boolean} True if the drag just exceeded the drag radius for the + * first time. + * @private + */ + updateDragDelta_(currentXY) { + this.currentDragDeltaXY_ = Coordinate.difference( + currentXY, + /** @type {!Coordinate} */ (this.mouseDownXY_)); + + if (!this.hasExceededDragRadius_) { + const currentDragDelta = Coordinate.magnitude(this.currentDragDeltaXY_); + + // The flyout has a different drag radius from the rest of Blockly. + const limitRadius = this.flyout_ ? internalConstants.FLYOUT_DRAG_RADIUS : + internalConstants.DRAG_RADIUS; + + this.hasExceededDragRadius_ = currentDragDelta > limitRadius; + return this.hasExceededDragRadius_; + } return false; } - this.isDraggingBubble_ = true; - this.startDraggingBubble_(); - return true; -}; - -/** - * Update this gesture to record whether a block is being dragged. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * If a block should be dragged, either from the flyout or in the workspace, - * this function creates the necessary BlockDragger and starts the drag. - * @return {boolean} True if a block is being dragged. - * @private - */ -Gesture.prototype.updateIsDraggingBlock_ = function() { - if (!this.targetBlock_) { - return false; - } - - if (this.flyout_) { - this.isDraggingBlock_ = this.updateIsDraggingFromFlyout_(); - } else if (this.targetBlock_.isMovable()) { - this.isDraggingBlock_ = true; - } - - if (this.isDraggingBlock_) { - this.startDraggingBlock_(); - return true; - } - return false; -}; - -/** - * Update this gesture to record whether a workspace is being dragged. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * If a workspace is being dragged this function creates the necessary - * WorkspaceDragger and starts the drag. - * @private - */ -Gesture.prototype.updateIsDraggingWorkspace_ = function() { - const wsMovable = this.flyout_ ? - this.flyout_.isScrollable() : - this.startWorkspace_ && this.startWorkspace_.isDraggable(); - - if (!wsMovable) { - return; - } - - this.workspaceDragger_ = new WorkspaceDragger( - /** @type {!WorkspaceSvg} */ (this.startWorkspace_)); - - this.isDraggingWorkspace_ = true; - this.workspaceDragger_.startDrag(); -}; - -/** - * Update this gesture to record whether anything is being dragged. - * This function should be called on a mouse/touch move event the first time the - * drag radius is exceeded. It should be called no more than once per gesture. - * @private - */ -Gesture.prototype.updateIsDragging_ = function() { - // Sanity check. - if (this.calledUpdateIsDragging_) { - throw Error('updateIsDragging_ should only be called once per gesture.'); - } - this.calledUpdateIsDragging_ = true; - - // First check if it was a bubble drag. Bubbles always sit on top of blocks. - if (this.updateIsDraggingBubble_()) { - return; - } - // Then check if it was a block drag. - if (this.updateIsDraggingBlock_()) { - return; - } - // Then check if it's a workspace drag. - this.updateIsDraggingWorkspace_(); -}; - -/** - * Create a block dragger and start dragging the selected block. - * @private - */ -Gesture.prototype.startDraggingBlock_ = function() { - const BlockDraggerClass = registry.getClassFromOptions( - registry.Type.BLOCK_DRAGGER, this.creatorWorkspace_.options, true); - - this.blockDragger_ = new BlockDraggerClass( - /** @type {!BlockSvg} */ (this.targetBlock_), - /** @type {!WorkspaceSvg} */ (this.startWorkspace_)); - this.blockDragger_.startDrag(this.currentDragDeltaXY_, this.healStack_); - this.blockDragger_.drag(this.mostRecentEvent_, this.currentDragDeltaXY_); -}; - -/** - * Create a bubble dragger and start dragging the selected bubble. - * @private - */ -// TODO (fenichel): Possibly combine this and startDraggingBlock_. -Gesture.prototype.startDraggingBubble_ = function() { - this.bubbleDragger_ = new BubbleDragger( - /** @type {!IBubble} */ (this.startBubble_), - /** @type {!WorkspaceSvg} */ (this.startWorkspace_)); - this.bubbleDragger_.startBubbleDrag(); - this.bubbleDragger_.dragBubble( - this.mostRecentEvent_, this.currentDragDeltaXY_); -}; -/** - * Start a gesture: update the workspace to indicate that a gesture is in - * progress and bind mousemove and mouseup handlers. - * @param {!Event} e A mouse down or touch start event. - * @package - */ -Gesture.prototype.doStart = function(e) { - if (browserEvents.isTargetInput(e)) { - this.cancel(); - return; - } - this.hasStarted_ = true; - - blockAnimations.disconnectUiStop(); - this.startWorkspace_.updateScreenCalculationsIfScrolled(); - if (this.startWorkspace_.isMutator) { - // Mutator's coordinate system could be out of date because the bubble was - // dragged, the block was moved, the parent workspace zoomed, etc. - this.startWorkspace_.resize(); - } - - // Hide chaff also hides the flyout, so don't do it if the click is in a - // flyout. - this.startWorkspace_.hideChaff(!!this.flyout_); - - this.startWorkspace_.markFocused(); - this.mostRecentEvent_ = e; - - Tooltip.block(); - - if (this.targetBlock_) { - this.targetBlock_.select(); - } - - if (browserEvents.isRightButton(e)) { - this.handleRightClick(e); - return; - } - - if ((e.type.toLowerCase() === 'touchstart' || - e.type.toLowerCase() === 'pointerdown') && - e.pointerType !== 'mouse') { - Touch.longStart(e, this); - } - - this.mouseDownXY_ = new Coordinate(e.clientX, e.clientY); - this.healStack_ = e.altKey || e.ctrlKey || e.metaKey; - - this.bindMouseEvents(e); -}; - -/** - * Bind gesture events. - * @param {!Event} e A mouse down or touch start event. - * @package - */ -Gesture.prototype.bindMouseEvents = function(e) { - this.onMoveWrapper_ = browserEvents.conditionalBind( - document, 'mousemove', null, this.handleMove.bind(this)); - this.onUpWrapper_ = browserEvents.conditionalBind( - document, 'mouseup', null, this.handleUp.bind(this)); - - e.preventDefault(); - e.stopPropagation(); -}; - -/** - * Handle a mouse move or touch move event. - * @param {!Event} e A mouse move or touch move event. - * @package - */ -Gesture.prototype.handleMove = function(e) { - this.updateFromEvent_(e); - if (this.isDraggingWorkspace_) { - this.workspaceDragger_.drag(this.currentDragDeltaXY_); - } else if (this.isDraggingBlock_) { - this.blockDragger_.drag(this.mostRecentEvent_, this.currentDragDeltaXY_); - } else if (this.isDraggingBubble_) { - this.bubbleDragger_.dragBubble( - this.mostRecentEvent_, this.currentDragDeltaXY_); - } - e.preventDefault(); - e.stopPropagation(); -}; - -/** - * Handle a mouse up or touch end event. - * @param {!Event} e A mouse up or touch end event. - * @package - */ -Gesture.prototype.handleUp = function(e) { - this.updateFromEvent_(e); - Touch.longStop(); - - if (this.isEnding_) { - console.log('Trying to end a gesture recursively.'); - return; - } - this.isEnding_ = true; - // The ordering of these checks is important: drags have higher priority than - // clicks. Fields have higher priority than blocks; blocks have higher - // priority than workspaces. - // The ordering within drags does not matter, because the three types of - // dragging are exclusive. - if (this.isDraggingBubble_) { - this.bubbleDragger_.endBubbleDrag(e, this.currentDragDeltaXY_); - } else if (this.isDraggingBlock_) { - this.blockDragger_.endDrag(e, this.currentDragDeltaXY_); - } else if (this.isDraggingWorkspace_) { - this.workspaceDragger_.endDrag(this.currentDragDeltaXY_); - } else if (this.isBubbleClick_()) { - // Bubbles are in front of all fields and blocks. - this.doBubbleClick_(); - } else if (this.isFieldClick_()) { - this.doFieldClick_(); - } else if (this.isBlockClick_()) { - this.doBlockClick_(); - } else if (this.isWorkspaceClick_()) { - this.doWorkspaceClick_(e); - } - - e.preventDefault(); - e.stopPropagation(); - - this.dispose(); -}; - -/** - * Cancel an in-progress gesture. If a workspace or block drag is in progress, - * end the drag at the most recent location. - * @package - */ -Gesture.prototype.cancel = function() { - // Disposing of a block cancels in-progress drags, but dragging to a delete - // area disposes of a block and leads to recursive disposal. Break that cycle. - if (this.isEnding_) { - return; - } - Touch.longStop(); - if (this.isDraggingBubble_) { - this.bubbleDragger_.endBubbleDrag( - this.mostRecentEvent_, this.currentDragDeltaXY_); - } else if (this.isDraggingBlock_) { - this.blockDragger_.endDrag(this.mostRecentEvent_, this.currentDragDeltaXY_); - } else if (this.isDraggingWorkspace_) { - this.workspaceDragger_.endDrag(this.currentDragDeltaXY_); - } - this.dispose(); -}; - -/** - * Handle a real or faked right-click event by showing a context menu. - * @param {!Event} e A mouse move or touch move event. - * @package - */ -Gesture.prototype.handleRightClick = function(e) { - if (this.targetBlock_) { - this.bringBlockToFront_(); - this.targetBlock_.workspace.hideChaff(!!this.flyout_); - this.targetBlock_.showContextMenu(e); - } else if (this.startBubble_) { - this.startBubble_.showContextMenu(e); - } else if (this.startWorkspace_ && !this.flyout_) { - this.startWorkspace_.hideChaff(); - this.startWorkspace_.showContextMenu(e); - } - - // TODO: Handle right-click on a bubble. - e.preventDefault(); - e.stopPropagation(); - - this.dispose(); -}; - -/** - * Handle a mousedown/touchstart event on a workspace. - * @param {!Event} e A mouse down or touch start event. - * @param {!WorkspaceSvg} ws The workspace the event hit. - * @package - */ -Gesture.prototype.handleWsStart = function(e, ws) { - if (this.hasStarted_) { - throw Error( - 'Tried to call gesture.handleWsStart, ' + - 'but the gesture had already been started.'); - } - this.setStartWorkspace_(ws); - this.mostRecentEvent_ = e; - this.doStart(e); -}; - -/** - * Fires a workspace click event. - * @param {!WorkspaceSvg} ws The workspace that a user clicks on. - * @private - */ -Gesture.prototype.fireWorkspaceClick_ = function(ws) { - eventUtils.fire( - new (eventUtils.get(eventUtils.CLICK))(null, ws.id, 'workspace')); -}; - -/** - * Handle a mousedown/touchstart event on a flyout. - * @param {!Event} e A mouse down or touch start event. - * @param {!IFlyout} flyout The flyout the event hit. - * @package - */ -Gesture.prototype.handleFlyoutStart = function(e, flyout) { - if (this.hasStarted_) { - throw Error( - 'Tried to call gesture.handleFlyoutStart, ' + - 'but the gesture had already been started.'); - } - this.setStartFlyout_(flyout); - this.handleWsStart(e, flyout.getWorkspace()); -}; - -/** - * Handle a mousedown/touchstart event on a block. - * @param {!Event} e A mouse down or touch start event. - * @param {!BlockSvg} block The block the event hit. - * @package - */ -Gesture.prototype.handleBlockStart = function(e, block) { - if (this.hasStarted_) { - throw Error( - 'Tried to call gesture.handleBlockStart, ' + - 'but the gesture had already been started.'); - } - this.setStartBlock(block); - this.mostRecentEvent_ = e; -}; - -/** - * Handle a mousedown/touchstart event on a bubble. - * @param {!Event} e A mouse down or touch start event. - * @param {!IBubble} bubble The bubble the event hit. - * @package - */ -Gesture.prototype.handleBubbleStart = function(e, bubble) { - if (this.hasStarted_) { - throw Error( - 'Tried to call gesture.handleBubbleStart, ' + - 'but the gesture had already been started.'); - } - this.setStartBubble(bubble); - this.mostRecentEvent_ = e; -}; - -/* Begin functions defining what actions to take to execute clicks on each type - * of target. Any developer wanting to add behaviour on clicks should modify - * only this code. */ - -/** - * Execute a bubble click. - * @private - */ -Gesture.prototype.doBubbleClick_ = function() { - // TODO (#1673): Consistent handling of single clicks. - this.startBubble_.setFocus && this.startBubble_.setFocus(); - this.startBubble_.select && this.startBubble_.select(); -}; - -/** - * Execute a field click. - * @private - */ -Gesture.prototype.doFieldClick_ = function() { - this.startField_.showEditor(this.mostRecentEvent_); - this.bringBlockToFront_(); -}; - -/** - * Execute a block click. - * @private - */ -Gesture.prototype.doBlockClick_ = function() { - // Block click in an autoclosing flyout. - if (this.flyout_ && this.flyout_.autoClose) { - if (this.targetBlock_.isEnabled()) { + /** + * Update this gesture to record whether a block is being dragged from the + * flyout. + * This function should be called on a mouse/touch move event the first time + * the drag radius is exceeded. It should be called no more than once per + * gesture. If a block should be dragged from the flyout this function creates + * the new block on the main workspace and updates targetBlock_ and + * startWorkspace_. + * @return {boolean} True if a block is being dragged from the flyout. + * @private + */ + updateIsDraggingFromFlyout_() { + if (!this.targetBlock_) { + return false; + } + if (!this.flyout_.isBlockCreatable_(this.targetBlock_)) { + return false; + } + if (!this.flyout_.isScrollable() || + this.flyout_.isDragTowardWorkspace(this.currentDragDeltaXY_)) { + this.startWorkspace_ = this.flyout_.targetWorkspace; + this.startWorkspace_.updateScreenCalculationsIfScrolled(); + // Start the event group now, so that the same event group is used for + // block creation and block dragging. if (!eventUtils.getGroup()) { eventUtils.setGroup(true); } - const newBlock = this.flyout_.createBlock(this.targetBlock_); - newBlock.scheduleSnapAndBump(); - } - } else { - // Clicks events are on the start block, even if it was a shadow. - const event = new (eventUtils.get(eventUtils.CLICK))( - this.startBlock_, this.startWorkspace_.id, 'block'); - eventUtils.fire(event); - } - this.bringBlockToFront_(); - eventUtils.setGroup(false); -}; - -/** - * Execute a workspace click. When in accessibility mode shift clicking will - * move the cursor. - * @param {!Event} _e A mouse up or touch end event. - * @private - */ -Gesture.prototype.doWorkspaceClick_ = function(_e) { - const ws = this.creatorWorkspace_; - if (common.getSelected()) { - common.getSelected().unselect(); - } - this.fireWorkspaceClick_(this.startWorkspace_ || ws); -}; - -/* End functions defining what actions to take to execute clicks on each type - * of target. */ - -// TODO (fenichel): Move bubbles to the front. -/** - * Move the dragged/clicked block to the front of the workspace so that it is - * not occluded by other blocks. - * @private - */ -Gesture.prototype.bringBlockToFront_ = function() { - // Blocks in the flyout don't overlap, so skip the work. - if (this.targetBlock_ && !this.flyout_) { - this.targetBlock_.bringToFront(); - } -}; - -/* Begin functions for populating a gesture at mouse down. */ - -/** - * Record the field that a gesture started on. - * @param {Field} field The field the gesture started on. - * @package - */ -Gesture.prototype.setStartField = function(field) { - if (this.hasStarted_) { - throw Error( - 'Tried to call gesture.setStartField, ' + - 'but the gesture had already been started.'); - } - if (!this.startField_) { - this.startField_ = field; - } -}; - -/** - * Record the bubble that a gesture started on - * @param {IBubble} bubble The bubble the gesture started on. - * @package - */ -Gesture.prototype.setStartBubble = function(bubble) { - if (!this.startBubble_) { - this.startBubble_ = bubble; - } -}; - -/** - * Record the block that a gesture started on, and set the target block - * appropriately. - * @param {BlockSvg} block The block the gesture started on. - * @package - */ -Gesture.prototype.setStartBlock = function(block) { - // If the gesture already went through a bubble, don't set the start block. - if (!this.startBlock_ && !this.startBubble_) { - this.startBlock_ = block; - if (block.isInFlyout && block !== block.getRootBlock()) { - this.setTargetBlock_(block.getRootBlock()); - } else { - this.setTargetBlock_(block); - } - } -}; - -/** - * Record the block that a gesture targets, meaning the block that will be - * dragged if this turns into a drag. If this block is a shadow, that will be - * its first non-shadow parent. - * @param {BlockSvg} block The block the gesture targets. - * @private - */ -Gesture.prototype.setTargetBlock_ = function(block) { - if (block.isShadow()) { - this.setTargetBlock_(block.getParent()); - } else { - this.targetBlock_ = block; - } -}; - -/** - * Record the workspace that a gesture started on. - * @param {WorkspaceSvg} ws The workspace the gesture started on. - * @private - */ -Gesture.prototype.setStartWorkspace_ = function(ws) { - if (!this.startWorkspace_) { - this.startWorkspace_ = ws; - } -}; - -/** - * Record the flyout that a gesture started on. - * @param {IFlyout} flyout The flyout the gesture started on. - * @private - */ -Gesture.prototype.setStartFlyout_ = function(flyout) { - if (!this.flyout_) { - this.flyout_ = flyout; - } -}; - - -/* End functions for populating a gesture at mouse down. */ - -/* Begin helper functions defining types of clicks. Any developer wanting - * to change the definition of a click should modify only this code. */ - -/** - * Whether this gesture is a click on a bubble. This should only be called when - * ending a gesture (mouse up, touch end). - * @return {boolean} Whether this gesture was a click on a bubble. - * @private - */ -Gesture.prototype.isBubbleClick_ = function() { - // A bubble click starts on a bubble and never escapes the drag radius. - const hasStartBubble = !!this.startBubble_; - return hasStartBubble && !this.hasExceededDragRadius_; -}; - -/** - * Whether this gesture is a click on a block. This should only be called when - * ending a gesture (mouse up, touch end). - * @return {boolean} Whether this gesture was a click on a block. - * @private - */ -Gesture.prototype.isBlockClick_ = function() { - // A block click starts on a block, never escapes the drag radius, and is not - // a field click. - const hasStartBlock = !!this.startBlock_; - return hasStartBlock && !this.hasExceededDragRadius_ && !this.isFieldClick_(); -}; - -/** - * Whether this gesture is a click on a field. This should only be called when - * ending a gesture (mouse up, touch end). - * @return {boolean} Whether this gesture was a click on a field. - * @private - */ -Gesture.prototype.isFieldClick_ = function() { - const fieldClickable = - this.startField_ ? this.startField_.isClickable() : false; - return fieldClickable && !this.hasExceededDragRadius_ && - (!this.flyout_ || !this.flyout_.autoClose); -}; - -/** - * Whether this gesture is a click on a workspace. This should only be called - * when ending a gesture (mouse up, touch end). - * @return {boolean} Whether this gesture was a click on a workspace. - * @private - */ -Gesture.prototype.isWorkspaceClick_ = function() { - const onlyTouchedWorkspace = - !this.startBlock_ && !this.startBubble_ && !this.startField_; - return onlyTouchedWorkspace && !this.hasExceededDragRadius_; -}; - -/* End helper functions defining types of clicks. */ - -/** - * Whether this gesture is a drag of either a workspace or block. - * This function is called externally to block actions that cannot be taken - * mid-drag (e.g. using the keyboard to delete the selected blocks). - * @return {boolean} True if this gesture is a drag of a workspace or block. - * @package - */ -Gesture.prototype.isDragging = function() { - return this.isDraggingWorkspace_ || this.isDraggingBlock_ || - this.isDraggingBubble_; -}; - -/** - * Whether this gesture has already been started. In theory every mouse down - * has a corresponding mouse up, but in reality it is possible to lose a - * mouse up, leaving an in-process gesture hanging. - * @return {boolean} Whether this gesture was a click on a workspace. - * @package - */ -Gesture.prototype.hasStarted = function() { - return this.hasStarted_; -}; - -/** - * Get a list of the insertion markers that currently exist. Block drags have - * 0, 1, or 2 insertion markers. - * @return {!Array} A possibly empty list of insertion - * marker blocks. - * @package - */ -Gesture.prototype.getInsertionMarkers = function() { - if (this.blockDragger_) { - return this.blockDragger_.getInsertionMarkers(); - } - return []; -}; - -/** - * Gets the current dragger if an item is being dragged. Null if nothing is - * being dragged. - * @return {!WorkspaceDragger|!BubbleDragger|!IBlockDragger|null} - * The dragger that is currently in use or null if no drag is in progress. - */ -Gesture.prototype.getCurrentDragger = function() { - if (this.isDraggingBlock_) { - return this.blockDragger_; - } else if (this.isDraggingWorkspace_) { - return this.workspaceDragger_; - } else if (this.isDraggingBubble_) { - return this.bubbleDragger_; - } - return null; -}; - -/** - * Is a drag or other gesture currently in progress on any workspace? - * @return {boolean} True if gesture is occurring. - */ -Gesture.inProgress = function() { - const workspaces = Workspace.getAll(); - for (let i = 0, workspace; (workspace = workspaces[i]); i++) { - if (workspace.currentGesture_) { + // The start block is no longer relevant, because this is a drag. + this.startBlock_ = null; + this.targetBlock_ = this.flyout_.createBlock(this.targetBlock_); + this.targetBlock_.select(); return true; } + return false; } - return false; -}; + + /** + * Update this gesture to record whether a bubble is being dragged. + * This function should be called on a mouse/touch move event the first time + * the drag radius is exceeded. It should be called no more than once per + * gesture. If a bubble should be dragged this function creates the necessary + * BubbleDragger and starts the drag. + * @return {boolean} True if a bubble is being dragged. + * @private + */ + updateIsDraggingBubble_() { + if (!this.startBubble_) { + return false; + } + + this.isDraggingBubble_ = true; + this.startDraggingBubble_(); + return true; + } + + /** + * Update this gesture to record whether a block is being dragged. + * This function should be called on a mouse/touch move event the first time + * the drag radius is exceeded. It should be called no more than once per + * gesture. If a block should be dragged, either from the flyout or in the + * workspace, this function creates the necessary BlockDragger and starts the + * drag. + * @return {boolean} True if a block is being dragged. + * @private + */ + updateIsDraggingBlock_() { + if (!this.targetBlock_) { + return false; + } + + if (this.flyout_) { + this.isDraggingBlock_ = this.updateIsDraggingFromFlyout_(); + } else if (this.targetBlock_.isMovable()) { + this.isDraggingBlock_ = true; + } + + if (this.isDraggingBlock_) { + this.startDraggingBlock_(); + return true; + } + return false; + } + + /** + * Update this gesture to record whether a workspace is being dragged. + * This function should be called on a mouse/touch move event the first time + * the drag radius is exceeded. It should be called no more than once per + * gesture. If a workspace is being dragged this function creates the + * necessary WorkspaceDragger and starts the drag. + * @private + */ + updateIsDraggingWorkspace_() { + const wsMovable = this.flyout_ ? + this.flyout_.isScrollable() : + this.startWorkspace_ && this.startWorkspace_.isDraggable(); + + if (!wsMovable) { + return; + } + + this.workspaceDragger_ = new WorkspaceDragger( + /** @type {!WorkspaceSvg} */ (this.startWorkspace_)); + + this.isDraggingWorkspace_ = true; + this.workspaceDragger_.startDrag(); + } + + /** + * Update this gesture to record whether anything is being dragged. + * This function should be called on a mouse/touch move event the first time + * the drag radius is exceeded. It should be called no more than once per + * gesture. + * @private + */ + updateIsDragging_() { + // Sanity check. + if (this.calledUpdateIsDragging_) { + throw Error('updateIsDragging_ should only be called once per gesture.'); + } + this.calledUpdateIsDragging_ = true; + + // First check if it was a bubble drag. Bubbles always sit on top of + // blocks. + if (this.updateIsDraggingBubble_()) { + return; + } + // Then check if it was a block drag. + if (this.updateIsDraggingBlock_()) { + return; + } + // Then check if it's a workspace drag. + this.updateIsDraggingWorkspace_(); + } + + /** + * Create a block dragger and start dragging the selected block. + * @private + */ + startDraggingBlock_() { + const BlockDraggerClass = registry.getClassFromOptions( + registry.Type.BLOCK_DRAGGER, this.creatorWorkspace_.options, true); + + this.blockDragger_ = new BlockDraggerClass( + /** @type {!BlockSvg} */ (this.targetBlock_), + /** @type {!WorkspaceSvg} */ (this.startWorkspace_)); + this.blockDragger_.startDrag(this.currentDragDeltaXY_, this.healStack_); + this.blockDragger_.drag(this.mostRecentEvent_, this.currentDragDeltaXY_); + } + + // TODO (fenichel): Possibly combine this and startDraggingBlock_. + /** + * Create a bubble dragger and start dragging the selected bubble. + * @private + */ + startDraggingBubble_() { + this.bubbleDragger_ = new BubbleDragger( + /** @type {!IBubble} */ (this.startBubble_), + /** @type {!WorkspaceSvg} */ (this.startWorkspace_)); + this.bubbleDragger_.startBubbleDrag(); + this.bubbleDragger_.dragBubble( + this.mostRecentEvent_, this.currentDragDeltaXY_); + } + + /** + * Start a gesture: update the workspace to indicate that a gesture is in + * progress and bind mousemove and mouseup handlers. + * @param {!Event} e A mouse down or touch start event. + * @package + */ + doStart(e) { + if (browserEvents.isTargetInput(e)) { + this.cancel(); + return; + } + this.hasStarted_ = true; + + blockAnimations.disconnectUiStop(); + this.startWorkspace_.updateScreenCalculationsIfScrolled(); + if (this.startWorkspace_.isMutator) { + // Mutator's coordinate system could be out of date because the bubble was + // dragged, the block was moved, the parent workspace zoomed, etc. + this.startWorkspace_.resize(); + } + + // Hide chaff also hides the flyout, so don't do it if the click is in a + // flyout. + this.startWorkspace_.hideChaff(!!this.flyout_); + + this.startWorkspace_.markFocused(); + this.mostRecentEvent_ = e; + + Tooltip.block(); + + if (this.targetBlock_) { + this.targetBlock_.select(); + } + + if (browserEvents.isRightButton(e)) { + this.handleRightClick(e); + return; + } + + if ((e.type.toLowerCase() === 'touchstart' || + e.type.toLowerCase() === 'pointerdown') && + e.pointerType !== 'mouse') { + Touch.longStart(e, this); + } + + this.mouseDownXY_ = new Coordinate(e.clientX, e.clientY); + this.healStack_ = e.altKey || e.ctrlKey || e.metaKey; + + this.bindMouseEvents(e); + } + + /** + * Bind gesture events. + * @param {!Event} e A mouse down or touch start event. + * @package + */ + bindMouseEvents(e) { + this.onMoveWrapper_ = browserEvents.conditionalBind( + document, 'mousemove', null, this.handleMove.bind(this)); + this.onUpWrapper_ = browserEvents.conditionalBind( + document, 'mouseup', null, this.handleUp.bind(this)); + + e.preventDefault(); + e.stopPropagation(); + } + + /** + * Handle a mouse move or touch move event. + * @param {!Event} e A mouse move or touch move event. + * @package + */ + handleMove(e) { + this.updateFromEvent_(e); + if (this.isDraggingWorkspace_) { + this.workspaceDragger_.drag(this.currentDragDeltaXY_); + } else if (this.isDraggingBlock_) { + this.blockDragger_.drag(this.mostRecentEvent_, this.currentDragDeltaXY_); + } else if (this.isDraggingBubble_) { + this.bubbleDragger_.dragBubble( + this.mostRecentEvent_, this.currentDragDeltaXY_); + } + e.preventDefault(); + e.stopPropagation(); + } + + /** + * Handle a mouse up or touch end event. + * @param {!Event} e A mouse up or touch end event. + * @package + */ + handleUp(e) { + this.updateFromEvent_(e); + Touch.longStop(); + + if (this.isEnding_) { + console.log('Trying to end a gesture recursively.'); + return; + } + this.isEnding_ = true; + // The ordering of these checks is important: drags have higher priority + // than clicks. Fields have higher priority than blocks; blocks have higher + // priority than workspaces. + // The ordering within drags does not matter, because the three types of + // dragging are exclusive. + if (this.isDraggingBubble_) { + this.bubbleDragger_.endBubbleDrag(e, this.currentDragDeltaXY_); + } else if (this.isDraggingBlock_) { + this.blockDragger_.endDrag(e, this.currentDragDeltaXY_); + } else if (this.isDraggingWorkspace_) { + this.workspaceDragger_.endDrag(this.currentDragDeltaXY_); + } else if (this.isBubbleClick_()) { + // Bubbles are in front of all fields and blocks. + this.doBubbleClick_(); + } else if (this.isFieldClick_()) { + this.doFieldClick_(); + } else if (this.isBlockClick_()) { + this.doBlockClick_(); + } else if (this.isWorkspaceClick_()) { + this.doWorkspaceClick_(e); + } + + e.preventDefault(); + e.stopPropagation(); + + this.dispose(); + } + + /** + * Cancel an in-progress gesture. If a workspace or block drag is in + * progress, end the drag at the most recent location. + * @package + */ + cancel() { + // Disposing of a block cancels in-progress drags, but dragging to a delete + // area disposes of a block and leads to recursive disposal. Break that + // cycle. + if (this.isEnding_) { + return; + } + Touch.longStop(); + if (this.isDraggingBubble_) { + this.bubbleDragger_.endBubbleDrag( + this.mostRecentEvent_, this.currentDragDeltaXY_); + } else if (this.isDraggingBlock_) { + this.blockDragger_.endDrag( + this.mostRecentEvent_, this.currentDragDeltaXY_); + } else if (this.isDraggingWorkspace_) { + this.workspaceDragger_.endDrag(this.currentDragDeltaXY_); + } + this.dispose(); + } + + /** + * Handle a real or faked right-click event by showing a context menu. + * @param {!Event} e A mouse move or touch move event. + * @package + */ + handleRightClick(e) { + if (this.targetBlock_) { + this.bringBlockToFront_(); + this.targetBlock_.workspace.hideChaff(!!this.flyout_); + this.targetBlock_.showContextMenu(e); + } else if (this.startBubble_) { + this.startBubble_.showContextMenu(e); + } else if (this.startWorkspace_ && !this.flyout_) { + this.startWorkspace_.hideChaff(); + this.startWorkspace_.showContextMenu(e); + } + + // TODO: Handle right-click on a bubble. + e.preventDefault(); + e.stopPropagation(); + + this.dispose(); + } + + /** + * Handle a mousedown/touchstart event on a workspace. + * @param {!Event} e A mouse down or touch start event. + * @param {!WorkspaceSvg} ws The workspace the event hit. + * @package + */ + handleWsStart(e, ws) { + if (this.hasStarted_) { + throw Error( + 'Tried to call gesture.handleWsStart, ' + + 'but the gesture had already been started.'); + } + this.setStartWorkspace_(ws); + this.mostRecentEvent_ = e; + this.doStart(e); + } + + /** + * Fires a workspace click event. + * @param {!WorkspaceSvg} ws The workspace that a user clicks on. + * @private + */ + fireWorkspaceClick_(ws) { + eventUtils.fire( + new (eventUtils.get(eventUtils.CLICK))(null, ws.id, 'workspace')); + } + + /** + * Handle a mousedown/touchstart event on a flyout. + * @param {!Event} e A mouse down or touch start event. + * @param {!IFlyout} flyout The flyout the event hit. + * @package + */ + handleFlyoutStart(e, flyout) { + if (this.hasStarted_) { + throw Error( + 'Tried to call gesture.handleFlyoutStart, ' + + 'but the gesture had already been started.'); + } + this.setStartFlyout_(flyout); + this.handleWsStart(e, flyout.getWorkspace()); + } + + /** + * Handle a mousedown/touchstart event on a block. + * @param {!Event} e A mouse down or touch start event. + * @param {!BlockSvg} block The block the event hit. + * @package + */ + handleBlockStart(e, block) { + if (this.hasStarted_) { + throw Error( + 'Tried to call gesture.handleBlockStart, ' + + 'but the gesture had already been started.'); + } + this.setStartBlock(block); + this.mostRecentEvent_ = e; + } + + /** + * Handle a mousedown/touchstart event on a bubble. + * @param {!Event} e A mouse down or touch start event. + * @param {!IBubble} bubble The bubble the event hit. + * @package + */ + handleBubbleStart(e, bubble) { + if (this.hasStarted_) { + throw Error( + 'Tried to call gesture.handleBubbleStart, ' + + 'but the gesture had already been started.'); + } + this.setStartBubble(bubble); + this.mostRecentEvent_ = e; + } + + /* Begin functions defining what actions to take to execute clicks on each + * type of target. Any developer wanting to add behaviour on clicks should + * modify only this code. */ + + /** + * Execute a bubble click. + * @private + */ + doBubbleClick_() { + // TODO (#1673): Consistent handling of single clicks. + this.startBubble_.setFocus && this.startBubble_.setFocus(); + this.startBubble_.select && this.startBubble_.select(); + } + + /** + * Execute a field click. + * @private + */ + doFieldClick_() { + this.startField_.showEditor(this.mostRecentEvent_); + this.bringBlockToFront_(); + } + + /** + * Execute a block click. + * @private + */ + doBlockClick_() { + // Block click in an autoclosing flyout. + if (this.flyout_ && this.flyout_.autoClose) { + if (this.targetBlock_.isEnabled()) { + if (!eventUtils.getGroup()) { + eventUtils.setGroup(true); + } + const newBlock = this.flyout_.createBlock(this.targetBlock_); + newBlock.scheduleSnapAndBump(); + } + } else { + // Clicks events are on the start block, even if it was a shadow. + const event = new (eventUtils.get(eventUtils.CLICK))( + this.startBlock_, this.startWorkspace_.id, 'block'); + eventUtils.fire(event); + } + this.bringBlockToFront_(); + eventUtils.setGroup(false); + } + + /** + * Execute a workspace click. When in accessibility mode shift clicking will + * move the cursor. + * @param {!Event} _e A mouse up or touch end event. + * @private + */ + doWorkspaceClick_(_e) { + const ws = this.creatorWorkspace_; + if (common.getSelected()) { + common.getSelected().unselect(); + } + this.fireWorkspaceClick_(this.startWorkspace_ || ws); + } + + /* End functions defining what actions to take to execute clicks on each type + * of target. */ + + // TODO (fenichel): Move bubbles to the front. + + /** + * Move the dragged/clicked block to the front of the workspace so that it is + * not occluded by other blocks. + * @private + */ + bringBlockToFront_() { + // Blocks in the flyout don't overlap, so skip the work. + if (this.targetBlock_ && !this.flyout_) { + this.targetBlock_.bringToFront(); + } + } + + /* Begin functions for populating a gesture at mouse down. */ + + /** + * Record the field that a gesture started on. + * @param {Field} field The field the gesture started on. + * @package + */ + setStartField(field) { + if (this.hasStarted_) { + throw Error( + 'Tried to call gesture.setStartField, ' + + 'but the gesture had already been started.'); + } + if (!this.startField_) { + this.startField_ = field; + } + } + + /** + * Record the bubble that a gesture started on + * @param {IBubble} bubble The bubble the gesture started on. + * @package + */ + setStartBubble(bubble) { + if (!this.startBubble_) { + this.startBubble_ = bubble; + } + } + + /** + * Record the block that a gesture started on, and set the target block + * appropriately. + * @param {BlockSvg} block The block the gesture started on. + * @package + */ + setStartBlock(block) { + // If the gesture already went through a bubble, don't set the start block. + if (!this.startBlock_ && !this.startBubble_) { + this.startBlock_ = block; + if (block.isInFlyout && block !== block.getRootBlock()) { + this.setTargetBlock_(block.getRootBlock()); + } else { + this.setTargetBlock_(block); + } + } + } + + /** + * Record the block that a gesture targets, meaning the block that will be + * dragged if this turns into a drag. If this block is a shadow, that will be + * its first non-shadow parent. + * @param {BlockSvg} block The block the gesture targets. + * @private + */ + setTargetBlock_(block) { + if (block.isShadow()) { + this.setTargetBlock_(block.getParent()); + } else { + this.targetBlock_ = block; + } + } + + /** + * Record the workspace that a gesture started on. + * @param {WorkspaceSvg} ws The workspace the gesture started on. + * @private + */ + setStartWorkspace_(ws) { + if (!this.startWorkspace_) { + this.startWorkspace_ = ws; + } + } + + /** + * Record the flyout that a gesture started on. + * @param {IFlyout} flyout The flyout the gesture started on. + * @private + */ + setStartFlyout_(flyout) { + if (!this.flyout_) { + this.flyout_ = flyout; + } + } + + /* End functions for populating a gesture at mouse down. */ + + /* Begin helper functions defining types of clicks. Any developer wanting + * to change the definition of a click should modify only this code. */ + + /** + * Whether this gesture is a click on a bubble. This should only be called + * when ending a gesture (mouse up, touch end). + * @return {boolean} Whether this gesture was a click on a bubble. + * @private + */ + isBubbleClick_() { + // A bubble click starts on a bubble and never escapes the drag radius. + const hasStartBubble = !!this.startBubble_; + return hasStartBubble && !this.hasExceededDragRadius_; + } + + /** + * Whether this gesture is a click on a block. This should only be called + * when ending a gesture (mouse up, touch end). + * @return {boolean} Whether this gesture was a click on a block. + * @private + */ + isBlockClick_() { + // A block click starts on a block, never escapes the drag radius, and is + // not a field click. + const hasStartBlock = !!this.startBlock_; + return hasStartBlock && !this.hasExceededDragRadius_ && + !this.isFieldClick_(); + } + + /** + * Whether this gesture is a click on a field. This should only be called + * when ending a gesture (mouse up, touch end). + * @return {boolean} Whether this gesture was a click on a field. + * @private + */ + isFieldClick_() { + const fieldClickable = + this.startField_ ? this.startField_.isClickable() : false; + return fieldClickable && !this.hasExceededDragRadius_ && + (!this.flyout_ || !this.flyout_.autoClose); + } + + /** + * Whether this gesture is a click on a workspace. This should only be called + * when ending a gesture (mouse up, touch end). + * @return {boolean} Whether this gesture was a click on a workspace. + * @private + */ + isWorkspaceClick_() { + const onlyTouchedWorkspace = + !this.startBlock_ && !this.startBubble_ && !this.startField_; + return onlyTouchedWorkspace && !this.hasExceededDragRadius_; + } + + /* End helper functions defining types of clicks. */ + + /** + * Whether this gesture is a drag of either a workspace or block. + * This function is called externally to block actions that cannot be taken + * mid-drag (e.g. using the keyboard to delete the selected blocks). + * @return {boolean} True if this gesture is a drag of a workspace or block. + * @package + */ + isDragging() { + return this.isDraggingWorkspace_ || this.isDraggingBlock_ || + this.isDraggingBubble_; + } + + /** + * Whether this gesture has already been started. In theory every mouse down + * has a corresponding mouse up, but in reality it is possible to lose a + * mouse up, leaving an in-process gesture hanging. + * @return {boolean} Whether this gesture was a click on a workspace. + * @package + */ + hasStarted() { + return this.hasStarted_; + } + + /** + * Get a list of the insertion markers that currently exist. Block drags have + * 0, 1, or 2 insertion markers. + * @return {!Array} A possibly empty list of insertion + * marker blocks. + * @package + */ + getInsertionMarkers() { + if (this.blockDragger_) { + return this.blockDragger_.getInsertionMarkers(); + } + return []; + } + + /** + * Gets the current dragger if an item is being dragged. Null if nothing is + * being dragged. + * @return {!WorkspaceDragger|!BubbleDragger|!IBlockDragger|null} + * The dragger that is currently in use or null if no drag is in progress. + */ + getCurrentDragger() { + if (this.isDraggingBlock_) { + return this.blockDragger_; + } else if (this.isDraggingWorkspace_) { + return this.workspaceDragger_; + } else if (this.isDraggingBubble_) { + return this.bubbleDragger_; + } + return null; + } + + /** + * Is a drag or other gesture currently in progress on any workspace? + * @return {boolean} True if gesture is occurring. + */ + static inProgress() { + const workspaces = Workspace.getAll(); + for (let i = 0, workspace; (workspace = workspaces[i]); i++) { + if (workspace.currentGesture_) { + return true; + } + } + return false; + } +} exports.Gesture = Gesture; diff --git a/core/grid.js b/core/grid.js index 950175607..272d909ea 100644 --- a/core/grid.js +++ b/core/grid.js @@ -24,200 +24,203 @@ const {Svg} = goog.require('Blockly.utils.Svg'); /** * Class for a workspace's grid. - * @param {!SVGElement} pattern The grid's SVG pattern, created during - * injection. - * @param {!Object} options A dictionary of normalized options for the grid. - * See grid documentation: - * https://developers.google.com/blockly/guides/configure/web/grid - * @constructor - * @alias Blockly.Grid */ -const Grid = function(pattern, options) { +class Grid { /** - * The scale of the grid, used to set stroke width on grid lines. - * This should always be the same as the workspace scale. - * @type {number} - * @private + * @param {!SVGElement} pattern The grid's SVG pattern, created during + * injection. + * @param {!Object} options A dictionary of normalized options for the grid. + * See grid documentation: + * https://developers.google.com/blockly/guides/configure/web/grid + * @alias Blockly.Grid */ - this.scale_ = 1; + constructor(pattern, options) { + /** + * The scale of the grid, used to set stroke width on grid lines. + * This should always be the same as the workspace scale. + * @type {number} + * @private + */ + this.scale_ = 1; - /** - * The grid's SVG pattern, created during injection. - * @type {!SVGElement} - * @private - */ - this.gridPattern_ = pattern; + /** + * The grid's SVG pattern, created during injection. + * @type {!SVGElement} + * @private + */ + this.gridPattern_ = pattern; - /** - * The spacing of the grid lines (in px). - * @type {number} - * @private - */ - this.spacing_ = options['spacing']; + /** + * The spacing of the grid lines (in px). + * @type {number} + * @private + */ + this.spacing_ = options['spacing']; - /** - * How long the grid lines should be (in px). - * @type {number} - * @private - */ - this.length_ = options['length']; + /** + * How long the grid lines should be (in px). + * @type {number} + * @private + */ + this.length_ = options['length']; - /** - * The horizontal grid line, if it exists. - * @type {SVGElement} - * @private - */ - this.line1_ = /** @type {SVGElement} */ (pattern.firstChild); + /** + * The horizontal grid line, if it exists. + * @type {SVGElement} + * @private + */ + this.line1_ = /** @type {SVGElement} */ (pattern.firstChild); - /** - * The vertical grid line, if it exists. - * @type {SVGElement} - * @private - */ - this.line2_ = - this.line1_ && (/** @type {SVGElement} */ (this.line1_.nextSibling)); + /** + * The vertical grid line, if it exists. + * @type {SVGElement} + * @private + */ + this.line2_ = + this.line1_ && (/** @type {SVGElement} */ (this.line1_.nextSibling)); - /** - * Whether blocks should snap to the grid. - * @type {boolean} - * @private - */ - this.snapToGrid_ = options['snap']; -}; - -/** - * Dispose of this grid and unlink from the DOM. - * @package - * @suppress {checkTypes} - */ -Grid.prototype.dispose = function() { - this.gridPattern_ = null; -}; - -/** - * Whether blocks should snap to the grid, based on the initial configuration. - * @return {boolean} True if blocks should snap, false otherwise. - * @package - */ -Grid.prototype.shouldSnap = function() { - return this.snapToGrid_; -}; - -/** - * Get the spacing of the grid points (in px). - * @return {number} The spacing of the grid points. - * @package - */ -Grid.prototype.getSpacing = function() { - return this.spacing_; -}; - -/** - * Get the ID of the pattern element, which should be randomized to avoid - * conflicts with other Blockly instances on the page. - * @return {string} The pattern ID. - * @package - */ -Grid.prototype.getPatternId = function() { - return this.gridPattern_.id; -}; - -/** - * Update the grid with a new scale. - * @param {number} scale The new workspace scale. - * @package - */ -Grid.prototype.update = function(scale) { - this.scale_ = scale; - // MSIE freaks if it sees a 0x0 pattern, so set empty patterns to 100x100. - const safeSpacing = (this.spacing_ * scale) || 100; - - this.gridPattern_.setAttribute('width', safeSpacing); - this.gridPattern_.setAttribute('height', safeSpacing); - - let half = Math.floor(this.spacing_ / 2) + 0.5; - let start = half - this.length_ / 2; - let end = half + this.length_ / 2; - - half *= scale; - start *= scale; - end *= scale; - - this.setLineAttributes_(this.line1_, scale, start, end, half, half); - this.setLineAttributes_(this.line2_, scale, half, half, start, end); -}; - -/** - * Set the attributes on one of the lines in the grid. Use this to update the - * length and stroke width of the grid lines. - * @param {SVGElement} line Which line to update. - * @param {number} width The new stroke size (in px). - * @param {number} x1 The new x start position of the line (in px). - * @param {number} x2 The new x end position of the line (in px). - * @param {number} y1 The new y start position of the line (in px). - * @param {number} y2 The new y end position of the line (in px). - * @private - */ -Grid.prototype.setLineAttributes_ = function(line, width, x1, x2, y1, y2) { - if (line) { - line.setAttribute('stroke-width', width); - line.setAttribute('x1', x1); - line.setAttribute('y1', y1); - line.setAttribute('x2', x2); - line.setAttribute('y2', y2); + /** + * Whether blocks should snap to the grid. + * @type {boolean} + * @private + */ + this.snapToGrid_ = options['snap']; } -}; -/** - * Move the grid to a new x and y position, and make sure that change is - * visible. - * @param {number} x The new x position of the grid (in px). - * @param {number} y The new y position of the grid (in px). - * @package - */ -Grid.prototype.moveTo = function(x, y) { - this.gridPattern_.setAttribute('x', x); - this.gridPattern_.setAttribute('y', y); - - if (userAgent.IE || userAgent.EDGE) { - // IE/Edge doesn't notice that the x/y offsets have changed. - // Force an update. - this.update(this.scale_); + /** + * Dispose of this grid and unlink from the DOM. + * @package + * @suppress {checkTypes} + */ + dispose() { + this.gridPattern_ = null; } -}; -/** - * Create the DOM for the grid described by options. - * @param {string} rnd A random ID to append to the pattern's ID. - * @param {!Object} gridOptions The object containing grid configuration. - * @param {!SVGElement} defs The root SVG element for this workspace's defs. - * @return {!SVGElement} The SVG element for the grid pattern. - * @package - */ -Grid.createDom = function(rnd, gridOptions, defs) { - /* - - - - - */ - const gridPattern = dom.createSvgElement( - Svg.PATTERN, - {'id': 'blocklyGridPattern' + rnd, 'patternUnits': 'userSpaceOnUse'}, - defs); - if (gridOptions['length'] > 0 && gridOptions['spacing'] > 0) { - dom.createSvgElement( - Svg.LINE, {'stroke': gridOptions['colour']}, gridPattern); - if (gridOptions['length'] > 1) { + /** + * Whether blocks should snap to the grid, based on the initial configuration. + * @return {boolean} True if blocks should snap, false otherwise. + * @package + */ + shouldSnap() { + return this.snapToGrid_; + } + + /** + * Get the spacing of the grid points (in px). + * @return {number} The spacing of the grid points. + * @package + */ + getSpacing() { + return this.spacing_; + } + + /** + * Get the ID of the pattern element, which should be randomized to avoid + * conflicts with other Blockly instances on the page. + * @return {string} The pattern ID. + * @package + */ + getPatternId() { + return this.gridPattern_.id; + } + + /** + * Update the grid with a new scale. + * @param {number} scale The new workspace scale. + * @package + */ + update(scale) { + this.scale_ = scale; + // MSIE freaks if it sees a 0x0 pattern, so set empty patterns to 100x100. + const safeSpacing = (this.spacing_ * scale) || 100; + + this.gridPattern_.setAttribute('width', safeSpacing); + this.gridPattern_.setAttribute('height', safeSpacing); + + let half = Math.floor(this.spacing_ / 2) + 0.5; + let start = half - this.length_ / 2; + let end = half + this.length_ / 2; + + half *= scale; + start *= scale; + end *= scale; + + this.setLineAttributes_(this.line1_, scale, start, end, half, half); + this.setLineAttributes_(this.line2_, scale, half, half, start, end); + } + + /** + * Set the attributes on one of the lines in the grid. Use this to update the + * length and stroke width of the grid lines. + * @param {SVGElement} line Which line to update. + * @param {number} width The new stroke size (in px). + * @param {number} x1 The new x start position of the line (in px). + * @param {number} x2 The new x end position of the line (in px). + * @param {number} y1 The new y start position of the line (in px). + * @param {number} y2 The new y end position of the line (in px). + * @private + */ + setLineAttributes_(line, width, x1, x2, y1, y2) { + if (line) { + line.setAttribute('stroke-width', width); + line.setAttribute('x1', x1); + line.setAttribute('y1', y1); + line.setAttribute('x2', x2); + line.setAttribute('y2', y2); + } + } + + /** + * Move the grid to a new x and y position, and make sure that change is + * visible. + * @param {number} x The new x position of the grid (in px). + * @param {number} y The new y position of the grid (in px). + * @package + */ + moveTo(x, y) { + this.gridPattern_.setAttribute('x', x); + this.gridPattern_.setAttribute('y', y); + + if (userAgent.IE || userAgent.EDGE) { + // IE/Edge doesn't notice that the x/y offsets have changed. + // Force an update. + this.update(this.scale_); + } + } + + /** + * Create the DOM for the grid described by options. + * @param {string} rnd A random ID to append to the pattern's ID. + * @param {!Object} gridOptions The object containing grid configuration. + * @param {!SVGElement} defs The root SVG element for this workspace's defs. + * @return {!SVGElement} The SVG element for the grid pattern. + * @package + */ + static createDom(rnd, gridOptions, defs) { + /* + + + + + */ + const gridPattern = dom.createSvgElement( + Svg.PATTERN, + {'id': 'blocklyGridPattern' + rnd, 'patternUnits': 'userSpaceOnUse'}, + defs); + if (gridOptions['length'] > 0 && gridOptions['spacing'] > 0) { dom.createSvgElement( Svg.LINE, {'stroke': gridOptions['colour']}, gridPattern); + if (gridOptions['length'] > 1) { + dom.createSvgElement( + Svg.LINE, {'stroke': gridOptions['colour']}, gridPattern); + } + // x1, y1, x1, x2 properties will be set later in update. + } else { + // Edge 16 doesn't handle empty patterns + dom.createSvgElement(Svg.LINE, {}, gridPattern); } - // x1, y1, x1, x2 properties will be set later in update. - } else { - // Edge 16 doesn't handle empty patterns - dom.createSvgElement(Svg.LINE, {}, gridPattern); + return gridPattern; } - return gridPattern; -}; +} exports.Grid = Grid; diff --git a/core/input.js b/core/input.js index 318063022..c4f1fa074 100644 --- a/core/input.js +++ b/core/input.js @@ -15,18 +15,6 @@ */ goog.module('Blockly.Input'); -/** - * Enum for alignment of inputs. - * @enum {number} - * @alias Blockly.Input.Align - */ -const Align = { - LEFT: -1, - CENTRE: 0, - RIGHT: 1, -}; -exports.Align = Align; - const fieldRegistry = goog.require('Blockly.fieldRegistry'); /* eslint-disable-next-line no-unused-vars */ const {BlockSvg} = goog.requireType('Blockly.BlockSvg'); @@ -42,286 +30,303 @@ const {inputTypes} = goog.require('Blockly.inputTypes'); /** @suppress {extraRequire} */ goog.require('Blockly.FieldLabel'); + +/** + * Enum for alignment of inputs. + * @enum {number} + * @alias Blockly.Input.Align + */ +const Align = { + LEFT: -1, + CENTRE: 0, + RIGHT: 1, +}; +exports.Align = Align; + /** * Class for an input with an optional field. - * @param {number} type The type of the input. - * @param {string} name Language-neutral identifier which may used to find this - * input again. - * @param {!Block} block The block containing this input. - * @param {Connection} connection Optional connection for this input. - * @constructor - * @alias Blockly.Input */ -const Input = function(type, name, block, connection) { - if (type !== inputTypes.DUMMY && !name) { - throw Error('Value inputs and statement inputs must have non-empty name.'); +class Input { + /** + * @param {number} type The type of the input. + * @param {string} name Language-neutral identifier which may used to find + * this input again. + * @param {!Block} block The block containing this input. + * @param {Connection} connection Optional connection for this input. + * @alias Blockly.Input + */ + constructor(type, name, block, connection) { + if (type !== inputTypes.DUMMY && !name) { + throw Error( + 'Value inputs and statement inputs must have non-empty name.'); + } + /** @type {number} */ + this.type = type; + /** @type {string} */ + this.name = name; + /** + * @type {!Block} + * @private + */ + this.sourceBlock_ = block; + /** @type {Connection} */ + this.connection = connection; + /** @type {!Array} */ + this.fieldRow = []; + + /** + * Alignment of input's fields (left, right or centre). + * @type {number} + */ + this.align = Align.LEFT; + + /** + * Is the input visible? + * @type {boolean} + * @private + */ + this.visible_ = true; } - /** @type {number} */ - this.type = type; - /** @type {string} */ - this.name = name; - /** - * @type {!Block} - * @private - */ - this.sourceBlock_ = block; - /** @type {Connection} */ - this.connection = connection; - /** @type {!Array} */ - this.fieldRow = []; /** - * Alignment of input's fields (left, right or centre). - * @type {number} + * Get the source block for this input. + * @return {?Block} The source block, or null if there is none. */ - this.align = Align.LEFT; - - /** - * Is the input visible? - * @type {boolean} - * @private - */ - this.visible_ = true; -}; - - -/** - * Get the source block for this input. - * @return {?Block} The source block, or null if there is none. - */ -Input.prototype.getSourceBlock = function() { - return this.sourceBlock_; -}; - -/** - * Add a field (or label from string), and all prefix and suffix fields, to the - * end of the input's field row. - * @param {string|!Field} field Something to add as a field. - * @param {string=} opt_name Language-neutral identifier which may used to find - * this field again. Should be unique to the host block. - * @return {!Input} The input being append to (to allow chaining). - */ -Input.prototype.appendField = function(field, opt_name) { - this.insertFieldAt(this.fieldRow.length, field, opt_name); - return this; -}; - -/** - * Inserts a field (or label from string), and all prefix and suffix fields, at - * the location of the input's field row. - * @param {number} index The index at which to insert field. - * @param {string|!Field} field Something to add as a field. - * @param {string=} opt_name Language-neutral identifier which may used to find - * this field again. Should be unique to the host block. - * @return {number} The index following the last inserted field. - */ -Input.prototype.insertFieldAt = function(index, field, opt_name) { - if (index < 0 || index > this.fieldRow.length) { - throw Error('index ' + index + ' out of bounds.'); + getSourceBlock() { + return this.sourceBlock_; } - // Falsy field values don't generate a field, unless the field is an empty - // string and named. - if (!field && !(field === '' && opt_name)) { + + /** + * Add a field (or label from string), and all prefix and suffix fields, to + * the end of the input's field row. + * @param {string|!Field} field Something to add as a field. + * @param {string=} opt_name Language-neutral identifier which may used to + * find this field again. Should be unique to the host block. + * @return {!Input} The input being append to (to allow chaining). + */ + appendField(field, opt_name) { + this.insertFieldAt(this.fieldRow.length, field, opt_name); + return this; + } + + /** + * Inserts a field (or label from string), and all prefix and suffix fields, + * at the location of the input's field row. + * @param {number} index The index at which to insert field. + * @param {string|!Field} field Something to add as a field. + * @param {string=} opt_name Language-neutral identifier which may used to + * find this field again. Should be unique to the host block. + * @return {number} The index following the last inserted field. + */ + insertFieldAt(index, field, opt_name) { + if (index < 0 || index > this.fieldRow.length) { + throw Error('index ' + index + ' out of bounds.'); + } + // Falsy field values don't generate a field, unless the field is an empty + // string and named. + if (!field && !(field === '' && opt_name)) { + return index; + } + + // Generate a FieldLabel when given a plain text field. + if (typeof field === 'string') { + field = /** @type {!Field} **/ (fieldRegistry.fromJson({ + 'type': 'field_label', + 'text': field, + })); + } + + field.setSourceBlock(this.sourceBlock_); + if (this.sourceBlock_.rendered) { + field.init(); + field.applyColour(); + } + field.name = opt_name; + field.setVisible(this.isVisible()); + + if (field.prefixField) { + // Add any prefix. + index = this.insertFieldAt(index, field.prefixField); + } + // Add the field to the field row. + this.fieldRow.splice(index, 0, field); + index++; + if (field.suffixField) { + // Add any suffix. + index = this.insertFieldAt(index, field.suffixField); + } + + if (this.sourceBlock_.rendered) { + this.sourceBlock_ = /** @type {!BlockSvg} */ (this.sourceBlock_); + this.sourceBlock_.render(); + // Adding a field will cause the block to change shape. + this.sourceBlock_.bumpNeighbours(); + } return index; } - // Generate a FieldLabel when given a plain text field. - if (typeof field === 'string') { - field = /** @type {!Field} **/ (fieldRegistry.fromJson({ - 'type': 'field_label', - 'text': field, - })); - } - - field.setSourceBlock(this.sourceBlock_); - if (this.sourceBlock_.rendered) { - field.init(); - field.applyColour(); - } - field.name = opt_name; - field.setVisible(this.isVisible()); - - if (field.prefixField) { - // Add any prefix. - index = this.insertFieldAt(index, field.prefixField); - } - // Add the field to the field row. - this.fieldRow.splice(index, 0, field); - index++; - if (field.suffixField) { - // Add any suffix. - index = this.insertFieldAt(index, field.suffixField); - } - - if (this.sourceBlock_.rendered) { - this.sourceBlock_ = /** @type {!BlockSvg} */ (this.sourceBlock_); - this.sourceBlock_.render(); - // Adding a field will cause the block to change shape. - this.sourceBlock_.bumpNeighbours(); - } - return index; -}; - -/** - * Remove a field from this input. - * @param {string} name The name of the field. - * @param {boolean=} opt_quiet True to prevent an error if field is not present. - * @return {boolean} True if operation succeeds, false if field is not present - * and opt_quiet is true. - * @throws {Error} if the field is not present and opt_quiet is false. - */ -Input.prototype.removeField = function(name, opt_quiet) { - for (let i = 0, field; (field = this.fieldRow[i]); i++) { - if (field.name === name) { - field.dispose(); - this.fieldRow.splice(i, 1); - if (this.sourceBlock_.rendered) { - this.sourceBlock_ = /** @type {!BlockSvg} */ (this.sourceBlock_); - this.sourceBlock_.render(); - // Removing a field will cause the block to change shape. - this.sourceBlock_.bumpNeighbours(); + /** + * Remove a field from this input. + * @param {string} name The name of the field. + * @param {boolean=} opt_quiet True to prevent an error if field is not + * present. + * @return {boolean} True if operation succeeds, false if field is not present + * and opt_quiet is true. + * @throws {Error} if the field is not present and opt_quiet is false. + */ + removeField(name, opt_quiet) { + for (let i = 0, field; (field = this.fieldRow[i]); i++) { + if (field.name === name) { + field.dispose(); + this.fieldRow.splice(i, 1); + if (this.sourceBlock_.rendered) { + this.sourceBlock_ = /** @type {!BlockSvg} */ (this.sourceBlock_); + this.sourceBlock_.render(); + // Removing a field will cause the block to change shape. + this.sourceBlock_.bumpNeighbours(); + } + return true; } - return true; } + if (opt_quiet) { + return false; + } + throw Error('Field "' + name + '" not found.'); } - if (opt_quiet) { - return false; + + /** + * Gets whether this input is visible or not. + * @return {boolean} True if visible. + */ + isVisible() { + return this.visible_; } - throw Error('Field "' + name + '" not found.'); -}; -/** - * Gets whether this input is visible or not. - * @return {boolean} True if visible. - */ -Input.prototype.isVisible = function() { - return this.visible_; -}; + /** + * Sets whether this input is visible or not. + * Should only be used to collapse/uncollapse a block. + * @param {boolean} visible True if visible. + * @return {!Array} List of blocks to render. + * @package + */ + setVisible(visible) { + // Note: Currently there are only unit tests for block.setCollapsed() + // because this function is package. If this function goes back to being a + // public API tests (lots of tests) should be added. + let renderList = []; + if (this.visible_ === visible) { + return renderList; + } + this.visible_ = visible; -/** - * Sets whether this input is visible or not. - * Should only be used to collapse/uncollapse a block. - * @param {boolean} visible True if visible. - * @return {!Array} List of blocks to render. - * @package - */ -Input.prototype.setVisible = function(visible) { - // Note: Currently there are only unit tests for block.setCollapsed() - // because this function is package. If this function goes back to being a - // public API tests (lots of tests) should be added. - let renderList = []; - if (this.visible_ === visible) { + for (let y = 0, field; (field = this.fieldRow[y]); y++) { + field.setVisible(visible); + } + if (this.connection) { + this.connection = + /** @type {!RenderedConnection} */ (this.connection); + // Has a connection. + if (visible) { + renderList = this.connection.startTrackingAll(); + } else { + this.connection.stopTrackingAll(); + } + const child = this.connection.targetBlock(); + if (child) { + child.getSvgRoot().style.display = visible ? 'block' : 'none'; + } + } return renderList; } - this.visible_ = visible; - for (let y = 0, field; (field = this.fieldRow[y]); y++) { - field.setVisible(visible); - } - if (this.connection) { - this.connection = - /** @type {!RenderedConnection} */ (this.connection); - // Has a connection. - if (visible) { - renderList = this.connection.startTrackingAll(); - } else { - this.connection.stopTrackingAll(); - } - const child = this.connection.targetBlock(); - if (child) { - child.getSvgRoot().style.display = visible ? 'block' : 'none'; + /** + * Mark all fields on this input as dirty. + * @package + */ + markDirty() { + for (let y = 0, field; (field = this.fieldRow[y]); y++) { + field.markDirty(); } } - return renderList; -}; -/** - * Mark all fields on this input as dirty. - * @package - */ -Input.prototype.markDirty = function() { - for (let y = 0, field; (field = this.fieldRow[y]); y++) { - field.markDirty(); + /** + * Change a connection's compatibility. + * @param {string|Array|null} check Compatible value type or + * list of value types. Null if all types are compatible. + * @return {!Input} The input being modified (to allow chaining). + */ + setCheck(check) { + if (!this.connection) { + throw Error('This input does not have a connection.'); + } + this.connection.setCheck(check); + return this; } -}; -/** - * Change a connection's compatibility. - * @param {string|Array|null} check Compatible value type or - * list of value types. Null if all types are compatible. - * @return {!Input} The input being modified (to allow chaining). - */ -Input.prototype.setCheck = function(check) { - if (!this.connection) { - throw Error('This input does not have a connection.'); + /** + * Change the alignment of the connection's field(s). + * @param {number} align One of the values of Align + * In RTL mode directions are reversed, and Align.RIGHT aligns to the left. + * @return {!Input} The input being modified (to allow chaining). + */ + setAlign(align) { + this.align = align; + if (this.sourceBlock_.rendered) { + this.sourceBlock_ = /** @type {!BlockSvg} */ (this.sourceBlock_); + this.sourceBlock_.render(); + } + return this; } - this.connection.setCheck(check); - return this; -}; -/** - * Change the alignment of the connection's field(s). - * @param {number} align One of the values of Align - * In RTL mode directions are reversed, and Align.RIGHT aligns to the left. - * @return {!Input} The input being modified (to allow chaining). - */ -Input.prototype.setAlign = function(align) { - this.align = align; - if (this.sourceBlock_.rendered) { - this.sourceBlock_ = /** @type {!BlockSvg} */ (this.sourceBlock_); - this.sourceBlock_.render(); + /** + * Changes the connection's shadow block. + * @param {?Element} shadow DOM representation of a block or null. + * @return {!Input} The input being modified (to allow chaining). + */ + setShadowDom(shadow) { + if (!this.connection) { + throw Error('This input does not have a connection.'); + } + this.connection.setShadowDom(shadow); + return this; } - return this; -}; -/** - * Changes the connection's shadow block. - * @param {?Element} shadow DOM representation of a block or null. - * @return {!Input} The input being modified (to allow chaining). - */ -Input.prototype.setShadowDom = function(shadow) { - if (!this.connection) { - throw Error('This input does not have a connection.'); + /** + * Returns the XML representation of the connection's shadow block. + * @return {?Element} Shadow DOM representation of a block or null. + */ + getShadowDom() { + if (!this.connection) { + throw Error('This input does not have a connection.'); + } + return this.connection.getShadowDom(); } - this.connection.setShadowDom(shadow); - return this; -}; -/** - * Returns the XML representation of the connection's shadow block. - * @return {?Element} Shadow DOM representation of a block or null. - */ -Input.prototype.getShadowDom = function() { - if (!this.connection) { - throw Error('This input does not have a connection.'); + /** + * Initialize the fields on this input. + */ + init() { + if (!this.sourceBlock_.workspace.rendered) { + return; // Headless blocks don't need fields initialized. + } + for (let i = 0; i < this.fieldRow.length; i++) { + this.fieldRow[i].init(); + } } - return this.connection.getShadowDom(); -}; -/** - * Initialize the fields on this input. - */ -Input.prototype.init = function() { - if (!this.sourceBlock_.workspace.rendered) { - return; // Headless blocks don't need fields initialized. + /** + * Sever all links to this input. + * @suppress {checkTypes} + */ + dispose() { + for (let i = 0, field; (field = this.fieldRow[i]); i++) { + field.dispose(); + } + if (this.connection) { + this.connection.dispose(); + } + this.sourceBlock_ = null; } - for (let i = 0; i < this.fieldRow.length; i++) { - this.fieldRow[i].init(); - } -}; - -/** - * Sever all links to this input. - * @suppress {checkTypes} - */ -Input.prototype.dispose = function() { - for (let i = 0, field; (field = this.fieldRow[i]); i++) { - field.dispose(); - } - if (this.connection) { - this.connection.dispose(); - } - this.sourceBlock_ = null; -}; +} exports.Input = Input; diff --git a/core/insertion_marker_manager.js b/core/insertion_marker_manager.js index b2592d491..627eb44ff 100644 --- a/core/insertion_marker_manager.js +++ b/core/insertion_marker_manager.js @@ -36,119 +36,746 @@ const {RenderedConnection} = goog.requireType('Blockly.RenderedConnection'); const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg'); +/** + * An error message to throw if the block created by createMarkerBlock_ is + * missing any components. + * @type {string} + * @const + */ +const DUPLICATE_BLOCK_ERROR = 'The insertion marker ' + + 'manager tried to create a marker but the result is missing %1. If ' + + 'you are using a mutator, make sure your domToMutation method is ' + + 'properly defined.'; + + /** * Class that controls updates to connections during drags. It is primarily * responsible for finding the closest eligible connection and highlighting or * unhighlighting it as needed during a drag. - * @param {!BlockSvg} block The top block in the stack being dragged. - * @constructor - * @alias Blockly.InsertionMarkerManager */ -const InsertionMarkerManager = function(block) { - common.setSelected(block); +class InsertionMarkerManager { + /** + * @param {!BlockSvg} block The top block in the stack being dragged. + * @alias Blockly.InsertionMarkerManager + */ + constructor(block) { + common.setSelected(block); + + /** + * The top block in the stack being dragged. + * Does not change during a drag. + * @type {!BlockSvg} + * @private + */ + this.topBlock_ = block; + + /** + * The workspace on which these connections are being dragged. + * Does not change during a drag. + * @type {!WorkspaceSvg} + * @private + */ + this.workspace_ = block.workspace; + + /** + * The last connection on the stack, if it's not the last connection on the + * first block. + * Set in initAvailableConnections, if at all. + * @type {RenderedConnection} + * @private + */ + this.lastOnStack_ = null; + + /** + * The insertion marker corresponding to the last block in the stack, if + * that's not the same as the first block in the stack. + * Set in initAvailableConnections, if at all + * @type {BlockSvg} + * @private + */ + this.lastMarker_ = null; + + /** + * The insertion marker that shows up between blocks to show where a block + * would go if dropped immediately. + * @type {BlockSvg} + * @private + */ + this.firstMarker_ = this.createMarkerBlock_(this.topBlock_); + + /** + * The connection that this block would connect to if released immediately. + * Updated on every mouse move. + * This is not on any of the blocks that are being dragged. + * @type {RenderedConnection} + * @private + */ + this.closestConnection_ = null; + + /** + * The connection that would connect to this.closestConnection_ if this + * block were released immediately. Updated on every mouse move. This is on + * the top block that is being dragged or the last block in the dragging + * stack. + * @type {RenderedConnection} + * @private + */ + this.localConnection_ = null; + + /** + * Whether the block would be deleted if it were dropped immediately. + * Updated on every mouse move. + * @type {boolean} + * @private + */ + this.wouldDeleteBlock_ = false; + + /** + * Connection on the insertion marker block that corresponds to + * this.localConnection_ on the currently dragged block. + * @type {RenderedConnection} + * @private + */ + this.markerConnection_ = null; + + /** + * The block that currently has an input being highlighted, or null. + * @type {BlockSvg} + * @private + */ + this.highlightedBlock_ = null; + + /** + * The block being faded to indicate replacement, or null. + * @type {BlockSvg} + * @private + */ + this.fadedBlock_ = null; + + /** + * The connections on the dragging blocks that are available to connect to + * other blocks. This includes all open connections on the top block, as + * well as the last connection on the block stack. Does not change during a + * drag. + * @type {!Array} + * @private + */ + this.availableConnections_ = this.initAvailableConnections_(); + } /** - * The top block in the stack being dragged. - * Does not change during a drag. - * @type {!BlockSvg} - * @private + * Sever all links from this object. + * @package */ - this.topBlock_ = block; + dispose() { + this.availableConnections_.length = 0; + + eventUtils.disable(); + try { + if (this.firstMarker_) { + this.firstMarker_.dispose(); + } + if (this.lastMarker_) { + this.lastMarker_.dispose(); + } + } finally { + eventUtils.enable(); + } + } /** - * The workspace on which these connections are being dragged. - * Does not change during a drag. - * @type {!WorkspaceSvg} - * @private + * Update the available connections for the top block. These connections can + * change if a block is unplugged and the stack is healed. + * @package */ - this.workspace_ = block.workspace; + updateAvailableConnections() { + this.availableConnections_ = this.initAvailableConnections_(); + } /** - * The last connection on the stack, if it's not the last connection on the - * first block. - * Set in initAvailableConnections, if at all. - * @type {RenderedConnection} - * @private + * Return whether the block would be deleted if dropped immediately, based on + * information from the most recent move event. + * @return {boolean} True if the block would be deleted if dropped + * immediately. + * @package */ - this.lastOnStack_ = null; + wouldDeleteBlock() { + return this.wouldDeleteBlock_; + } /** - * The insertion marker corresponding to the last block in the stack, if - * that's not the same as the first block in the stack. - * Set in initAvailableConnections, if at all - * @type {BlockSvg} - * @private + * Return whether the block would be connected if dropped immediately, based + * on information from the most recent move event. + * @return {boolean} True if the block would be connected if dropped + * immediately. + * @package */ - this.lastMarker_ = null; + wouldConnectBlock() { + return !!this.closestConnection_; + } /** - * The insertion marker that shows up between blocks to show where a block - * would go if dropped immediately. - * @type {BlockSvg} - * @private + * Connect to the closest connection and render the results. + * This should be called at the end of a drag. + * @package */ - this.firstMarker_ = this.createMarkerBlock_(this.topBlock_); + applyConnections() { + if (this.closestConnection_) { + // Don't fire events for insertion markers. + eventUtils.disable(); + this.hidePreview_(); + eventUtils.enable(); + // Connect two blocks together. + this.localConnection_.connect(this.closestConnection_); + if (this.topBlock_.rendered) { + // Trigger a connection animation. + // Determine which connection is inferior (lower in the source stack). + const inferiorConnection = this.localConnection_.isSuperior() ? + this.closestConnection_ : + this.localConnection_; + blockAnimations.connectionUiEffect(inferiorConnection.getSourceBlock()); + // Bring the just-edited stack to the front. + const rootBlock = this.topBlock_.getRootBlock(); + rootBlock.bringToFront(); + } + } + } /** - * The connection that this block would connect to if released immediately. - * Updated on every mouse move. - * This is not on any of the blocks that are being dragged. - * @type {RenderedConnection} - * @private + * Update connections based on the most recent move location. + * @param {!Coordinate} dxy Position relative to drag start, + * in workspace units. + * @param {?IDragTarget} dragTarget The drag target that the block is + * currently over. + * @package */ - this.closestConnection_ = null; + update(dxy, dragTarget) { + const candidate = this.getCandidate_(dxy); + + this.wouldDeleteBlock_ = this.shouldDelete_(candidate, dragTarget); + + const shouldUpdate = + this.wouldDeleteBlock_ || this.shouldUpdatePreviews_(candidate, dxy); + + if (shouldUpdate) { + // Don't fire events for insertion marker creation or movement. + eventUtils.disable(); + this.maybeHidePreview_(candidate); + this.maybeShowPreview_(candidate); + eventUtils.enable(); + } + } /** - * The connection that would connect to this.closestConnection_ if this block - * were released immediately. - * Updated on every mouse move. - * This is on the top block that is being dragged or the last block in the - * dragging stack. - * @type {RenderedConnection} + * Create an insertion marker that represents the given block. + * @param {!BlockSvg} sourceBlock The block that the insertion marker + * will represent. + * @return {!BlockSvg} The insertion marker that represents the given + * block. * @private */ - this.localConnection_ = null; + createMarkerBlock_(sourceBlock) { + const imType = sourceBlock.type; + + eventUtils.disable(); + let result; + try { + result = this.workspace_.newBlock(imType); + result.setInsertionMarker(true); + if (sourceBlock.saveExtraState) { + const state = sourceBlock.saveExtraState(); + if (state) { + result.loadExtraState(state); + } + } else if (sourceBlock.mutationToDom) { + const oldMutationDom = sourceBlock.mutationToDom(); + if (oldMutationDom) { + result.domToMutation(oldMutationDom); + } + } + // Copy field values from the other block. These values may impact the + // rendered size of the insertion marker. Note that we do not care about + // child blocks here. + for (let i = 0; i < sourceBlock.inputList.length; i++) { + const sourceInput = sourceBlock.inputList[i]; + if (sourceInput.name === constants.COLLAPSED_INPUT_NAME) { + continue; // Ignore the collapsed input. + } + const resultInput = result.inputList[i]; + if (!resultInput) { + throw new Error(DUPLICATE_BLOCK_ERROR.replace('%1', 'an input')); + } + for (let j = 0; j < sourceInput.fieldRow.length; j++) { + const sourceField = sourceInput.fieldRow[j]; + const resultField = resultInput.fieldRow[j]; + if (!resultField) { + throw new Error(DUPLICATE_BLOCK_ERROR.replace('%1', 'a field')); + } + resultField.setValue(sourceField.getValue()); + } + } + + result.setCollapsed(sourceBlock.isCollapsed()); + result.setInputsInline(sourceBlock.getInputsInline()); + + result.initSvg(); + result.getSvgRoot().setAttribute('visibility', 'hidden'); + } finally { + eventUtils.enable(); + } + + return result; + } /** - * Whether the block would be deleted if it were dropped immediately. - * Updated on every mouse move. - * @type {boolean} + * Populate the list of available connections on this block stack. This + * should only be called once, at the beginning of a drag. If the stack has + * more than one block, this function will populate lastOnStack_ and create + * the corresponding insertion marker. + * @return {!Array} A list of available + * connections. * @private */ - this.wouldDeleteBlock_ = false; + initAvailableConnections_() { + const available = this.topBlock_.getConnections_(false); + // Also check the last connection on this stack + const lastOnStack = this.topBlock_.lastConnectionInStack(true); + if (lastOnStack && lastOnStack !== this.topBlock_.nextConnection) { + available.push(lastOnStack); + this.lastOnStack_ = lastOnStack; + if (this.lastMarker_) { + eventUtils.disable(); + try { + this.lastMarker_.dispose(); + } finally { + eventUtils.enable(); + } + } + this.lastMarker_ = this.createMarkerBlock_(lastOnStack.getSourceBlock()); + } + return available; + } /** - * Connection on the insertion marker block that corresponds to - * this.localConnection_ on the currently dragged block. - * @type {RenderedConnection} + * Whether the previews (insertion marker and replacement marker) should be + * updated based on the closest candidate and the current drag distance. + * @param {!Object} candidate An object containing a local connection, a + * closest connection, and a radius. Returned by getCandidate_. + * @param {!Coordinate} dxy Position relative to drag start, + * in workspace units. + * @return {boolean} Whether the preview should be updated. * @private */ - this.markerConnection_ = null; + shouldUpdatePreviews_(candidate, dxy) { + const candidateLocal = candidate.local; + const candidateClosest = candidate.closest; + const radius = candidate.radius; + + // Found a connection! + if (candidateLocal && candidateClosest) { + // We're already showing an insertion marker. + // Decide whether the new connection has higher priority. + if (this.localConnection_ && this.closestConnection_) { + // The connection was the same as the current connection. + if (this.closestConnection_ === candidateClosest && + this.localConnection_ === candidateLocal) { + return false; + } + const xDiff = + this.localConnection_.x + dxy.x - this.closestConnection_.x; + const yDiff = + this.localConnection_.y + dxy.y - this.closestConnection_.y; + const curDistance = Math.sqrt(xDiff * xDiff + yDiff * yDiff); + // Slightly prefer the existing preview over a new preview. + return !( + candidateClosest && + radius > + curDistance - internalConstants.CURRENT_CONNECTION_PREFERENCE); + } else if (!this.localConnection_ && !this.closestConnection_) { + // We weren't showing a preview before, but we should now. + return true; + } else { + console.error( + 'Only one of localConnection_ and closestConnection_ was set.'); + } + } else { // No connection found. + // Only need to update if we were showing a preview before. + return !!(this.localConnection_ && this.closestConnection_); + } + + console.error( + 'Returning true from shouldUpdatePreviews, but it\'s not clear why.'); + return true; + } /** - * The block that currently has an input being highlighted, or null. - * @type {BlockSvg} + * Find the nearest valid connection, which may be the same as the current + * closest connection. + * @param {!Coordinate} dxy Position relative to drag start, + * in workspace units. + * @return {!Object} An object containing a local connection, a closest + * connection, and a radius. * @private */ - this.highlightedBlock_ = null; + getCandidate_(dxy) { + let radius = this.getStartRadius_(); + let candidateClosest = null; + let candidateLocal = null; + + // It's possible that a block has added or removed connections during a + // drag, (e.g. in a drag/move event handler), so let's update the available + // connections. Note that this will be called on every move while dragging, + // so it might cause slowness, especially if the block stack is large. If + // so, maybe it could be made more efficient. Also note that we won't update + // the connections if we've already connected the insertion marker to a + // block. + if (!this.markerConnection_ || !this.markerConnection_.isConnected()) { + this.updateAvailableConnections(); + } + + for (let i = 0; i < this.availableConnections_.length; i++) { + const myConnection = this.availableConnections_[i]; + const neighbour = myConnection.closest(radius, dxy); + if (neighbour.connection) { + candidateClosest = neighbour.connection; + candidateLocal = myConnection; + radius = neighbour.radius; + } + } + return {closest: candidateClosest, local: candidateLocal, radius: radius}; + } /** - * The block being faded to indicate replacement, or null. - * @type {BlockSvg} + * Decide the radius at which to start searching for the closest connection. + * @return {number} The radius at which to start the search for the closest + * connection. * @private */ - this.fadedBlock_ = null; + getStartRadius_() { + // If there is already a connection highlighted, + // increase the radius we check for making new connections. + // Why? When a connection is highlighted, blocks move around when the + // insertion marker is created, which could cause the connection became out + // of range. By increasing radiusConnection when a connection already + // exists, we never "lose" the connection from the offset. + if (this.closestConnection_ && this.localConnection_) { + return internalConstants.CONNECTING_SNAP_RADIUS; + } + return internalConstants.SNAP_RADIUS; + } /** - * The connections on the dragging blocks that are available to connect to - * other blocks. This includes all open connections on the top block, as well - * as the last connection on the block stack. - * Does not change during a drag. - * @type {!Array} + * Whether ending the drag would delete the block. + * @param {!Object} candidate An object containing a local connection, a + * closest + * connection, and a radius. + * @param {?IDragTarget} dragTarget The drag target that the block is + * currently over. + * @return {boolean} Whether dropping the block immediately would delete the + * block. * @private */ - this.availableConnections_ = this.initAvailableConnections_(); -}; + shouldDelete_(candidate, dragTarget) { + if (dragTarget) { + const componentManager = this.workspace_.getComponentManager(); + const isDeleteArea = componentManager.hasCapability( + dragTarget.id, ComponentManager.Capability.DELETE_AREA); + if (isDeleteArea) { + return ( + /** @type {!IDeleteArea} */ (dragTarget)) + .wouldDelete(this.topBlock_, candidate && !!candidate.closest); + } + } + return false; + } + + /** + * Show an insertion marker or replacement highlighting during a drag, if + * needed. + * At the beginning of this function, this.localConnection_ and + * this.closestConnection_ should both be null. + * @param {!Object} candidate An object containing a local connection, a + * closest connection, and a radius. + * @private + */ + maybeShowPreview_(candidate) { + // Nope, don't add a marker. + if (this.wouldDeleteBlock_) { + return; + } + const closest = candidate.closest; + const local = candidate.local; + + // Nothing to connect to. + if (!closest) { + return; + } + + // Something went wrong and we're trying to connect to an invalid + // connection. + if (closest === this.closestConnection_ || + closest.getSourceBlock().isInsertionMarker()) { + console.log('Trying to connect to an insertion marker'); + return; + } + // Add an insertion marker or replacement marker. + this.closestConnection_ = closest; + this.localConnection_ = local; + this.showPreview_(); + } + + /** + * A preview should be shown. This function figures out if it should be a + * block highlight or an insertion marker, and shows the appropriate one. + * @private + */ + showPreview_() { + const closest = this.closestConnection_; + const renderer = this.workspace_.getRenderer(); + const method = renderer.getConnectionPreviewMethod( + /** @type {!RenderedConnection} */ (closest), + /** @type {!RenderedConnection} */ (this.localConnection_), + this.topBlock_); + + switch (method) { + case InsertionMarkerManager.PREVIEW_TYPE.INPUT_OUTLINE: + this.showInsertionInputOutline_(); + break; + case InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER: + this.showInsertionMarker_(); + break; + case InsertionMarkerManager.PREVIEW_TYPE.REPLACEMENT_FADE: + this.showReplacementFade_(); + break; + } + + // Optionally highlight the actual connection, as a nod to previous + // behaviour. + if (closest && renderer.shouldHighlightConnection(closest)) { + closest.highlight(); + } + } + + /** + * Show an insertion marker or replacement highlighting during a drag, if + * needed. + * At the end of this function, this.localConnection_ and + * this.closestConnection_ should both be null. + * @param {!Object} candidate An object containing a local connection, a + * closest connection, and a radius. + * @private + */ + maybeHidePreview_(candidate) { + // If there's no new preview, remove the old one but don't bother deleting + // it. We might need it later, and this saves disposing of it and recreating + // it. + if (!candidate.closest) { + this.hidePreview_(); + } else { + // If there's a new preview and there was an preview before, and either + // connection has changed, remove the old preview. + const hadPreview = this.closestConnection_ && this.localConnection_; + const closestChanged = this.closestConnection_ !== candidate.closest; + const localChanged = this.localConnection_ !== candidate.local; + + // Also hide if we had a preview before but now we're going to delete + // instead. + if (hadPreview && + (closestChanged || localChanged || this.wouldDeleteBlock_)) { + this.hidePreview_(); + } + } + + // Either way, clear out old state. + this.markerConnection_ = null; + this.closestConnection_ = null; + this.localConnection_ = null; + } + + /** + * A preview should be hidden. This function figures out if it is a block + * highlight or an insertion marker, and hides the appropriate one. + * @private + */ + hidePreview_() { + if (this.closestConnection_ && this.closestConnection_.targetBlock() && + this.workspace_.getRenderer().shouldHighlightConnection( + this.closestConnection_)) { + this.closestConnection_.unhighlight(); + } + if (this.fadedBlock_) { + this.hideReplacementFade_(); + } else if (this.highlightedBlock_) { + this.hideInsertionInputOutline_(); + } else if (this.markerConnection_) { + this.hideInsertionMarker_(); + } + } + + /** + * Shows an insertion marker connected to the appropriate blocks (based on + * manager state). + * @private + */ + showInsertionMarker_() { + const local = this.localConnection_; + const closest = this.closestConnection_; + + const isLastInStack = this.lastOnStack_ && local === this.lastOnStack_; + let imBlock = isLastInStack ? this.lastMarker_ : this.firstMarker_; + let imConn; + try { + imConn = imBlock.getMatchingConnection(local.getSourceBlock(), local); + } catch (e) { + // It's possible that the number of connections on the local block has + // changed since the insertion marker was originally created. Let's + // recreate the insertion marker and try again. In theory we could + // probably recreate the marker block (e.g. in getCandidate_), which is + // called more often during the drag, but creating a block that often + // might be too slow, so we only do it if necessary. + this.firstMarker_ = this.createMarkerBlock_(this.topBlock_); + imBlock = isLastInStack ? this.lastMarker_ : this.firstMarker_; + imConn = imBlock.getMatchingConnection(local.getSourceBlock(), local); + } + + if (imConn === this.markerConnection_) { + throw Error( + 'Made it to showInsertionMarker_ even though the marker isn\'t ' + + 'changing'); + } + + // Render disconnected from everything else so that we have a valid + // connection location. + imBlock.render(); + imBlock.rendered = true; + imBlock.getSvgRoot().setAttribute('visibility', 'visible'); + + if (imConn && closest) { + // Position so that the existing block doesn't move. + imBlock.positionNearConnection(imConn, closest); + } + if (closest) { + // Connect() also renders the insertion marker. + imConn.connect(closest); + } + + this.markerConnection_ = imConn; + } + + /** + * Disconnects and hides the current insertion marker. Should return the + * blocks to their original state. + * @private + */ + hideInsertionMarker_() { + if (!this.markerConnection_) { + console.log('No insertion marker connection to disconnect'); + return; + } + + const imConn = this.markerConnection_; + const imBlock = imConn.getSourceBlock(); + const markerNext = imBlock.nextConnection; + const markerPrev = imBlock.previousConnection; + const markerOutput = imBlock.outputConnection; + + const isFirstInStatementStack = + (imConn === markerNext && !(markerPrev && markerPrev.targetConnection)); + + const isFirstInOutputStack = imConn.type === ConnectionType.INPUT_VALUE && + !(markerOutput && markerOutput.targetConnection); + // The insertion marker is the first block in a stack. Unplug won't do + // anything in that case. Instead, unplug the following block. + if (isFirstInStatementStack || isFirstInOutputStack) { + imConn.targetBlock().unplug(false); + } else if ( + imConn.type === ConnectionType.NEXT_STATEMENT && + imConn !== markerNext) { + // Inside of a C-block, first statement connection. + const innerConnection = imConn.targetConnection; + innerConnection.getSourceBlock().unplug(false); + + const previousBlockNextConnection = + markerPrev ? markerPrev.targetConnection : null; + + imBlock.unplug(true); + if (previousBlockNextConnection) { + previousBlockNextConnection.connect(innerConnection); + } + } else { + imBlock.unplug(true /* healStack */); + } + + if (imConn.targetConnection) { + throw Error( + 'markerConnection_ still connected at the end of ' + + 'disconnectInsertionMarker'); + } + + this.markerConnection_ = null; + const svg = imBlock.getSvgRoot(); + if (svg) { + svg.setAttribute('visibility', 'hidden'); + } + } + + /** + * Shows an outline around the input the closest connection belongs to. + * @private + */ + showInsertionInputOutline_() { + const closest = this.closestConnection_; + this.highlightedBlock_ = closest.getSourceBlock(); + this.highlightedBlock_.highlightShapeForInput(closest, true); + } + + /** + * Hides any visible input outlines. + * @private + */ + hideInsertionInputOutline_() { + this.highlightedBlock_.highlightShapeForInput( + this.closestConnection_, false); + this.highlightedBlock_ = null; + } + + /** + * Shows a replacement fade affect on the closest connection's target block + * (the block that is currently connected to it). + * @private + */ + showReplacementFade_() { + this.fadedBlock_ = this.closestConnection_.targetBlock(); + this.fadedBlock_.fadeForReplacement(true); + } + + /** + * Hides/Removes any visible fade affects. + * @private + */ + hideReplacementFade_() { + this.fadedBlock_.fadeForReplacement(false); + this.fadedBlock_ = null; + } + + /** + * Get a list of the insertion markers that currently exist. Drags have 0, 1, + * or 2 insertion markers. + * @return {!Array} A possibly empty list of insertion + * marker blocks. + * @package + */ + getInsertionMarkers() { + const result = []; + if (this.firstMarker_) { + result.push(this.firstMarker_); + } + if (this.lastMarker_) { + result.push(this.lastMarker_); + } + return result; + } +} /** * An enum describing different kinds of previews the InsertionMarkerManager @@ -161,622 +788,4 @@ InsertionMarkerManager.PREVIEW_TYPE = { REPLACEMENT_FADE: 2, }; -/** - * An error message to throw if the block created by createMarkerBlock_ is - * missing any components. - * @type {string} - * @const - */ -InsertionMarkerManager.DUPLICATE_BLOCK_ERROR = 'The insertion marker ' + - 'manager tried to create a marker but the result is missing %1. If ' + - 'you are using a mutator, make sure your domToMutation method is ' + - 'properly defined.'; - -/** - * Sever all links from this object. - * @package - */ -InsertionMarkerManager.prototype.dispose = function() { - this.availableConnections_.length = 0; - - eventUtils.disable(); - try { - if (this.firstMarker_) { - this.firstMarker_.dispose(); - } - if (this.lastMarker_) { - this.lastMarker_.dispose(); - } - } finally { - eventUtils.enable(); - } -}; - -/** - * Update the available connections for the top block. These connections can - * change if a block is unplugged and the stack is healed. - * @package - */ -InsertionMarkerManager.prototype.updateAvailableConnections = function() { - this.availableConnections_ = this.initAvailableConnections_(); -}; - -/** - * Return whether the block would be deleted if dropped immediately, based on - * information from the most recent move event. - * @return {boolean} True if the block would be deleted if dropped immediately. - * @package - */ -InsertionMarkerManager.prototype.wouldDeleteBlock = function() { - return this.wouldDeleteBlock_; -}; - -/** - * Return whether the block would be connected if dropped immediately, based on - * information from the most recent move event. - * @return {boolean} True if the block would be connected if dropped - * immediately. - * @package - */ -InsertionMarkerManager.prototype.wouldConnectBlock = function() { - return !!this.closestConnection_; -}; - -/** - * Connect to the closest connection and render the results. - * This should be called at the end of a drag. - * @package - */ -InsertionMarkerManager.prototype.applyConnections = function() { - if (this.closestConnection_) { - // Don't fire events for insertion markers. - eventUtils.disable(); - this.hidePreview_(); - eventUtils.enable(); - // Connect two blocks together. - this.localConnection_.connect(this.closestConnection_); - if (this.topBlock_.rendered) { - // Trigger a connection animation. - // Determine which connection is inferior (lower in the source stack). - const inferiorConnection = this.localConnection_.isSuperior() ? - this.closestConnection_ : - this.localConnection_; - blockAnimations.connectionUiEffect(inferiorConnection.getSourceBlock()); - // Bring the just-edited stack to the front. - const rootBlock = this.topBlock_.getRootBlock(); - rootBlock.bringToFront(); - } - } -}; - -/** - * Update connections based on the most recent move location. - * @param {!Coordinate} dxy Position relative to drag start, - * in workspace units. - * @param {?IDragTarget} dragTarget The drag target that the block is - * currently over. - * @package - */ -InsertionMarkerManager.prototype.update = function(dxy, dragTarget) { - const candidate = this.getCandidate_(dxy); - - this.wouldDeleteBlock_ = this.shouldDelete_(candidate, dragTarget); - - const shouldUpdate = - this.wouldDeleteBlock_ || this.shouldUpdatePreviews_(candidate, dxy); - - if (shouldUpdate) { - // Don't fire events for insertion marker creation or movement. - eventUtils.disable(); - this.maybeHidePreview_(candidate); - this.maybeShowPreview_(candidate); - eventUtils.enable(); - } -}; - -/** - * Create an insertion marker that represents the given block. - * @param {!BlockSvg} sourceBlock The block that the insertion marker - * will represent. - * @return {!BlockSvg} The insertion marker that represents the given - * block. - * @private - */ -InsertionMarkerManager.prototype.createMarkerBlock_ = function(sourceBlock) { - const imType = sourceBlock.type; - - eventUtils.disable(); - let result; - try { - result = this.workspace_.newBlock(imType); - result.setInsertionMarker(true); - if (sourceBlock.saveExtraState) { - const state = sourceBlock.saveExtraState(); - if (state) { - result.loadExtraState(state); - } - } else if (sourceBlock.mutationToDom) { - const oldMutationDom = sourceBlock.mutationToDom(); - if (oldMutationDom) { - result.domToMutation(oldMutationDom); - } - } - // Copy field values from the other block. These values may impact the - // rendered size of the insertion marker. Note that we do not care about - // child blocks here. - for (let i = 0; i < sourceBlock.inputList.length; i++) { - const sourceInput = sourceBlock.inputList[i]; - if (sourceInput.name === constants.COLLAPSED_INPUT_NAME) { - continue; // Ignore the collapsed input. - } - const resultInput = result.inputList[i]; - if (!resultInput) { - throw new Error(InsertionMarkerManager.DUPLICATE_BLOCK_ERROR.replace( - '%1', 'an input')); - } - for (let j = 0; j < sourceInput.fieldRow.length; j++) { - const sourceField = sourceInput.fieldRow[j]; - const resultField = resultInput.fieldRow[j]; - if (!resultField) { - throw new Error(InsertionMarkerManager.DUPLICATE_BLOCK_ERROR.replace( - '%1', 'a field')); - } - resultField.setValue(sourceField.getValue()); - } - } - - result.setCollapsed(sourceBlock.isCollapsed()); - result.setInputsInline(sourceBlock.getInputsInline()); - - result.initSvg(); - result.getSvgRoot().setAttribute('visibility', 'hidden'); - } finally { - eventUtils.enable(); - } - - return result; -}; - -/** - * Populate the list of available connections on this block stack. This should - * only be called once, at the beginning of a drag. - * If the stack has more than one block, this function will populate - * lastOnStack_ and create the corresponding insertion marker. - * @return {!Array} A list of available - * connections. - * @private - */ -InsertionMarkerManager.prototype.initAvailableConnections_ = function() { - const available = this.topBlock_.getConnections_(false); - // Also check the last connection on this stack - const lastOnStack = this.topBlock_.lastConnectionInStack(true); - if (lastOnStack && lastOnStack !== this.topBlock_.nextConnection) { - available.push(lastOnStack); - this.lastOnStack_ = lastOnStack; - if (this.lastMarker_) { - eventUtils.disable(); - try { - this.lastMarker_.dispose(); - } finally { - eventUtils.enable(); - } - } - this.lastMarker_ = this.createMarkerBlock_(lastOnStack.getSourceBlock()); - } - return available; -}; - -/** - * Whether the previews (insertion marker and replacement marker) should be - * updated based on the closest candidate and the current drag distance. - * @param {!Object} candidate An object containing a local connection, a closest - * connection, and a radius. Returned by getCandidate_. - * @param {!Coordinate} dxy Position relative to drag start, - * in workspace units. - * @return {boolean} Whether the preview should be updated. - * @private - */ -InsertionMarkerManager.prototype.shouldUpdatePreviews_ = function( - candidate, dxy) { - const candidateLocal = candidate.local; - const candidateClosest = candidate.closest; - const radius = candidate.radius; - - // Found a connection! - if (candidateLocal && candidateClosest) { - // We're already showing an insertion marker. - // Decide whether the new connection has higher priority. - if (this.localConnection_ && this.closestConnection_) { - // The connection was the same as the current connection. - if (this.closestConnection_ === candidateClosest && - this.localConnection_ === candidateLocal) { - return false; - } - const xDiff = this.localConnection_.x + dxy.x - this.closestConnection_.x; - const yDiff = this.localConnection_.y + dxy.y - this.closestConnection_.y; - const curDistance = Math.sqrt(xDiff * xDiff + yDiff * yDiff); - // Slightly prefer the existing preview over a new preview. - return !( - candidateClosest && - radius > - curDistance - internalConstants.CURRENT_CONNECTION_PREFERENCE); - } else if (!this.localConnection_ && !this.closestConnection_) { - // We weren't showing a preview before, but we should now. - return true; - } else { - console.error( - 'Only one of localConnection_ and closestConnection_ was set.'); - } - } else { // No connection found. - // Only need to update if we were showing a preview before. - return !!(this.localConnection_ && this.closestConnection_); - } - - console.error( - 'Returning true from shouldUpdatePreviews, but it\'s not clear why.'); - return true; -}; - -/** - * Find the nearest valid connection, which may be the same as the current - * closest connection. - * @param {!Coordinate} dxy Position relative to drag start, - * in workspace units. - * @return {!Object} An object containing a local connection, a closest - * connection, and a radius. - * @private - */ -InsertionMarkerManager.prototype.getCandidate_ = function(dxy) { - let radius = this.getStartRadius_(); - let candidateClosest = null; - let candidateLocal = null; - - // It's possible that a block has added or removed connections during a drag, - // (e.g. in a drag/move event handler), so let's update the available - // connections. Note that this will be called on every move while dragging, so - // it might cause slowness, especially if the block stack is large. If so, - // maybe it could be made more efficient. Also note that we won't update the - // connections if we've already connected the insertion marker to a block. - if (!this.markerConnection_ || !this.markerConnection_.isConnected()) { - this.updateAvailableConnections(); - } - - for (let i = 0; i < this.availableConnections_.length; i++) { - const myConnection = this.availableConnections_[i]; - const neighbour = myConnection.closest(radius, dxy); - if (neighbour.connection) { - candidateClosest = neighbour.connection; - candidateLocal = myConnection; - radius = neighbour.radius; - } - } - return {closest: candidateClosest, local: candidateLocal, radius: radius}; -}; - -/** - * Decide the radius at which to start searching for the closest connection. - * @return {number} The radius at which to start the search for the closest - * connection. - * @private - */ -InsertionMarkerManager.prototype.getStartRadius_ = function() { - // If there is already a connection highlighted, - // increase the radius we check for making new connections. - // Why? When a connection is highlighted, blocks move around when the - // insertion marker is created, which could cause the connection became out of - // range. By increasing radiusConnection when a connection already exists, we - // never "lose" the connection from the offset. - if (this.closestConnection_ && this.localConnection_) { - return internalConstants.CONNECTING_SNAP_RADIUS; - } - return internalConstants.SNAP_RADIUS; -}; - -/** - * Whether ending the drag would delete the block. - * @param {!Object} candidate An object containing a local connection, a closest - * connection, and a radius. - * @param {?IDragTarget} dragTarget The drag target that the block is - * currently over. - * @return {boolean} Whether dropping the block immediately would delete the - * block. - * @private - */ -InsertionMarkerManager.prototype.shouldDelete_ = function( - candidate, dragTarget) { - if (dragTarget) { - const componentManager = this.workspace_.getComponentManager(); - const isDeleteArea = componentManager.hasCapability( - dragTarget.id, ComponentManager.Capability.DELETE_AREA); - if (isDeleteArea) { - return ( - /** @type {!IDeleteArea} */ (dragTarget)) - .wouldDelete(this.topBlock_, candidate && !!candidate.closest); - } - } - return false; -}; - -/** - * Show an insertion marker or replacement highlighting during a drag, if - * needed. - * At the beginning of this function, this.localConnection_ and - * this.closestConnection_ should both be null. - * @param {!Object} candidate An object containing a local connection, a closest - * connection, and a radius. - * @private - */ -InsertionMarkerManager.prototype.maybeShowPreview_ = function(candidate) { - // Nope, don't add a marker. - if (this.wouldDeleteBlock_) { - return; - } - const closest = candidate.closest; - const local = candidate.local; - - // Nothing to connect to. - if (!closest) { - return; - } - - // Something went wrong and we're trying to connect to an invalid connection. - if (closest === this.closestConnection_ || - closest.getSourceBlock().isInsertionMarker()) { - console.log('Trying to connect to an insertion marker'); - return; - } - // Add an insertion marker or replacement marker. - this.closestConnection_ = closest; - this.localConnection_ = local; - this.showPreview_(); -}; - -/** - * A preview should be shown. This function figures out if it should be a block - * highlight or an insertion marker, and shows the appropriate one. - * @private - */ -InsertionMarkerManager.prototype.showPreview_ = function() { - const closest = this.closestConnection_; - const renderer = this.workspace_.getRenderer(); - const method = renderer.getConnectionPreviewMethod( - /** @type {!RenderedConnection} */ (closest), - /** @type {!RenderedConnection} */ (this.localConnection_), - this.topBlock_); - - switch (method) { - case InsertionMarkerManager.PREVIEW_TYPE.INPUT_OUTLINE: - this.showInsertionInputOutline_(); - break; - case InsertionMarkerManager.PREVIEW_TYPE.INSERTION_MARKER: - this.showInsertionMarker_(); - break; - case InsertionMarkerManager.PREVIEW_TYPE.REPLACEMENT_FADE: - this.showReplacementFade_(); - break; - } - - // Optionally highlight the actual connection, as a nod to previous behaviour. - if (closest && renderer.shouldHighlightConnection(closest)) { - closest.highlight(); - } -}; - -/** - * Show an insertion marker or replacement highlighting during a drag, if - * needed. - * At the end of this function, this.localConnection_ and - * this.closestConnection_ should both be null. - * @param {!Object} candidate An object containing a local connection, a closest - * connection, and a radius. - * @private - */ -InsertionMarkerManager.prototype.maybeHidePreview_ = function(candidate) { - // If there's no new preview, remove the old one but don't bother deleting it. - // We might need it later, and this saves disposing of it and recreating it. - if (!candidate.closest) { - this.hidePreview_(); - } else { - // If there's a new preview and there was an preview before, and either - // connection has changed, remove the old preview. - const hadPreview = this.closestConnection_ && this.localConnection_; - const closestChanged = this.closestConnection_ !== candidate.closest; - const localChanged = this.localConnection_ !== candidate.local; - - // Also hide if we had a preview before but now we're going to delete - // instead. - if (hadPreview && - (closestChanged || localChanged || this.wouldDeleteBlock_)) { - this.hidePreview_(); - } - } - - // Either way, clear out old state. - this.markerConnection_ = null; - this.closestConnection_ = null; - this.localConnection_ = null; -}; - -/** - * A preview should be hidden. This function figures out if it is a block - * highlight or an insertion marker, and hides the appropriate one. - * @private - */ -InsertionMarkerManager.prototype.hidePreview_ = function() { - if (this.closestConnection_ && this.closestConnection_.targetBlock() && - this.workspace_.getRenderer().shouldHighlightConnection( - this.closestConnection_)) { - this.closestConnection_.unhighlight(); - } - if (this.fadedBlock_) { - this.hideReplacementFade_(); - } else if (this.highlightedBlock_) { - this.hideInsertionInputOutline_(); - } else if (this.markerConnection_) { - this.hideInsertionMarker_(); - } -}; - -/** - * Shows an insertion marker connected to the appropriate blocks (based on - * manager state). - * @private - */ -InsertionMarkerManager.prototype.showInsertionMarker_ = function() { - const local = this.localConnection_; - const closest = this.closestConnection_; - - const isLastInStack = this.lastOnStack_ && local === this.lastOnStack_; - let imBlock = isLastInStack ? this.lastMarker_ : this.firstMarker_; - let imConn; - try { - imConn = imBlock.getMatchingConnection(local.getSourceBlock(), local); - } catch (e) { - // It's possible that the number of connections on the local block has - // changed since the insertion marker was originally created. Let's - // recreate the insertion marker and try again. In theory we could probably - // recreate the marker block (e.g. in getCandidate_), which is called more - // often during the drag, but creating a block that often might be too slow, - // so we only do it if necessary. - this.firstMarker_ = this.createMarkerBlock_(this.topBlock_); - imBlock = isLastInStack ? this.lastMarker_ : this.firstMarker_; - imConn = imBlock.getMatchingConnection(local.getSourceBlock(), local); - } - - if (imConn === this.markerConnection_) { - throw Error( - 'Made it to showInsertionMarker_ even though the marker isn\'t ' + - 'changing'); - } - - // Render disconnected from everything else so that we have a valid - // connection location. - imBlock.render(); - imBlock.rendered = true; - imBlock.getSvgRoot().setAttribute('visibility', 'visible'); - - if (imConn && closest) { - // Position so that the existing block doesn't move. - imBlock.positionNearConnection(imConn, closest); - } - if (closest) { - // Connect() also renders the insertion marker. - imConn.connect(closest); - } - - this.markerConnection_ = imConn; -}; - -/** - * Disconnects and hides the current insertion marker. Should return the blocks - * to their original state. - * @private - */ -InsertionMarkerManager.prototype.hideInsertionMarker_ = function() { - if (!this.markerConnection_) { - console.log('No insertion marker connection to disconnect'); - return; - } - - const imConn = this.markerConnection_; - const imBlock = imConn.getSourceBlock(); - const markerNext = imBlock.nextConnection; - const markerPrev = imBlock.previousConnection; - const markerOutput = imBlock.outputConnection; - - const isFirstInStatementStack = - (imConn === markerNext && !(markerPrev && markerPrev.targetConnection)); - - const isFirstInOutputStack = imConn.type === ConnectionType.INPUT_VALUE && - !(markerOutput && markerOutput.targetConnection); - // The insertion marker is the first block in a stack. Unplug won't do - // anything in that case. Instead, unplug the following block. - if (isFirstInStatementStack || isFirstInOutputStack) { - imConn.targetBlock().unplug(false); - } else if ( - imConn.type === ConnectionType.NEXT_STATEMENT && imConn !== markerNext) { - // Inside of a C-block, first statement connection. - const innerConnection = imConn.targetConnection; - innerConnection.getSourceBlock().unplug(false); - - const previousBlockNextConnection = - markerPrev ? markerPrev.targetConnection : null; - - imBlock.unplug(true); - if (previousBlockNextConnection) { - previousBlockNextConnection.connect(innerConnection); - } - } else { - imBlock.unplug(true /* healStack */); - } - - if (imConn.targetConnection) { - throw Error( - 'markerConnection_ still connected at the end of ' + - 'disconnectInsertionMarker'); - } - - this.markerConnection_ = null; - const svg = imBlock.getSvgRoot(); - if (svg) { - svg.setAttribute('visibility', 'hidden'); - } -}; - -/** - * Shows an outline around the input the closest connection belongs to. - * @private - */ -InsertionMarkerManager.prototype.showInsertionInputOutline_ = function() { - const closest = this.closestConnection_; - this.highlightedBlock_ = closest.getSourceBlock(); - this.highlightedBlock_.highlightShapeForInput(closest, true); -}; - -/** - * Hides any visible input outlines. - * @private - */ -InsertionMarkerManager.prototype.hideInsertionInputOutline_ = function() { - this.highlightedBlock_.highlightShapeForInput(this.closestConnection_, false); - this.highlightedBlock_ = null; -}; - -/** - * Shows a replacement fade affect on the closest connection's target block - * (the block that is currently connected to it). - * @private - */ -InsertionMarkerManager.prototype.showReplacementFade_ = function() { - this.fadedBlock_ = this.closestConnection_.targetBlock(); - this.fadedBlock_.fadeForReplacement(true); -}; - -/** - * Hides/Removes any visible fade affects. - * @private - */ -InsertionMarkerManager.prototype.hideReplacementFade_ = function() { - this.fadedBlock_.fadeForReplacement(false); - this.fadedBlock_ = null; -}; - -/** - * Get a list of the insertion markers that currently exist. Drags have 0, 1, - * or 2 insertion markers. - * @return {!Array} A possibly empty list of insertion - * marker blocks. - * @package - */ -InsertionMarkerManager.prototype.getInsertionMarkers = function() { - const result = []; - if (this.firstMarker_) { - result.push(this.firstMarker_); - } - if (this.lastMarker_) { - result.push(this.lastMarker_); - } - return result; -}; - exports.InsertionMarkerManager = InsertionMarkerManager; diff --git a/core/metrics_manager.js b/core/metrics_manager.js index 072a812a7..11d4ae9fb 100644 --- a/core/metrics_manager.js +++ b/core/metrics_manager.js @@ -32,20 +32,409 @@ const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg'); /** * The manager for all workspace metrics calculations. - * @param {!WorkspaceSvg} workspace The workspace to calculate metrics - * for. * @implements {IMetricsManager} - * @constructor - * @alias Blockly.MetricsManager */ -const MetricsManager = function(workspace) { +class MetricsManager { /** - * The workspace to calculate metrics for. - * @type {!WorkspaceSvg} + * @param {!WorkspaceSvg} workspace The workspace to calculate metrics + * for. + * @alias Blockly.MetricsManager + */ + constructor(workspace) { + /** + * The workspace to calculate metrics for. + * @type {!WorkspaceSvg} + * @protected + */ + this.workspace_ = workspace; + } + + /** + * Gets the dimensions of the given workspace component, in pixel coordinates. + * @param {?IToolbox|?IFlyout} elem The element to get the + * dimensions of, or null. It should be a toolbox or flyout, and should + * implement getWidth() and getHeight(). + * @return {!Size} An object containing width and height + * attributes, which will both be zero if elem did not exist. * @protected */ - this.workspace_ = workspace; -}; + getDimensionsPx_(elem) { + let width = 0; + let height = 0; + if (elem) { + width = elem.getWidth(); + height = elem.getHeight(); + } + return new Size(width, height); + } + + /** + * Gets the width and the height of the flyout on the workspace in pixel + * coordinates. Returns 0 for the width and height if the workspace has a + * category toolbox instead of a simple toolbox. + * @param {boolean=} opt_own Whether to only return the workspace's own + * flyout. + * @return {!MetricsManager.ToolboxMetrics} The width and height of the + * flyout. + * @public + */ + getFlyoutMetrics(opt_own) { + const flyoutDimensions = + this.getDimensionsPx_(this.workspace_.getFlyout(opt_own)); + return { + width: flyoutDimensions.width, + height: flyoutDimensions.height, + position: this.workspace_.toolboxPosition, + }; + } + + /** + * Gets the width, height and position of the toolbox on the workspace in + * pixel coordinates. Returns 0 for the width and height if the workspace has + * a simple toolbox instead of a category toolbox. To get the width and height + * of a + * simple toolbox @see {@link getFlyoutMetrics}. + * @return {!MetricsManager.ToolboxMetrics} The object with the width, + * height and position of the toolbox. + * @public + */ + getToolboxMetrics() { + const toolboxDimensions = + this.getDimensionsPx_(this.workspace_.getToolbox()); + + return { + width: toolboxDimensions.width, + height: toolboxDimensions.height, + position: this.workspace_.toolboxPosition, + }; + } + + /** + * Gets the width and height of the workspace's parent SVG element in pixel + * coordinates. This area includes the toolbox and the visible workspace area. + * @return {!Size} The width and height of the workspace's parent + * SVG element. + * @public + */ + getSvgMetrics() { + return this.workspace_.getCachedParentSvgSize(); + } + + /** + * Gets the absolute left and absolute top in pixel coordinates. + * This is where the visible workspace starts in relation to the SVG + * container. + * @return {!MetricsManager.AbsoluteMetrics} The absolute metrics for + * the workspace. + * @public + */ + getAbsoluteMetrics() { + let absoluteLeft = 0; + const toolboxMetrics = this.getToolboxMetrics(); + const flyoutMetrics = this.getFlyoutMetrics(true); + const doesToolboxExist = !!this.workspace_.getToolbox(); + const doesFlyoutExist = !!this.workspace_.getFlyout(true); + const toolboxPosition = + doesToolboxExist ? toolboxMetrics.position : flyoutMetrics.position; + + const atLeft = toolboxPosition === toolboxUtils.Position.LEFT; + const atTop = toolboxPosition === toolboxUtils.Position.TOP; + if (doesToolboxExist && atLeft) { + absoluteLeft = toolboxMetrics.width; + } else if (doesFlyoutExist && atLeft) { + absoluteLeft = flyoutMetrics.width; + } + let absoluteTop = 0; + if (doesToolboxExist && atTop) { + absoluteTop = toolboxMetrics.height; + } else if (doesFlyoutExist && atTop) { + absoluteTop = flyoutMetrics.height; + } + + return { + top: absoluteTop, + left: absoluteLeft, + }; + } + + /** + * Gets the metrics for the visible workspace in either pixel or workspace + * coordinates. The visible workspace does not include the toolbox or flyout. + * @param {boolean=} opt_getWorkspaceCoordinates True to get the view metrics + * in workspace coordinates, false to get them in pixel coordinates. + * @return {!MetricsManager.ContainerRegion} The width, height, top and + * left of the viewport in either workspace coordinates or pixel + * coordinates. + * @public + */ + getViewMetrics(opt_getWorkspaceCoordinates) { + const scale = opt_getWorkspaceCoordinates ? this.workspace_.scale : 1; + const svgMetrics = this.getSvgMetrics(); + const toolboxMetrics = this.getToolboxMetrics(); + const flyoutMetrics = this.getFlyoutMetrics(true); + const doesToolboxExist = !!this.workspace_.getToolbox(); + const toolboxPosition = + doesToolboxExist ? toolboxMetrics.position : flyoutMetrics.position; + + if (this.workspace_.getToolbox()) { + if (toolboxPosition === toolboxUtils.Position.TOP || + toolboxPosition === toolboxUtils.Position.BOTTOM) { + svgMetrics.height -= toolboxMetrics.height; + } else if ( + toolboxPosition === toolboxUtils.Position.LEFT || + toolboxPosition === toolboxUtils.Position.RIGHT) { + svgMetrics.width -= toolboxMetrics.width; + } + } else if (this.workspace_.getFlyout(true)) { + if (toolboxPosition === toolboxUtils.Position.TOP || + toolboxPosition === toolboxUtils.Position.BOTTOM) { + svgMetrics.height -= flyoutMetrics.height; + } else if ( + toolboxPosition === toolboxUtils.Position.LEFT || + toolboxPosition === toolboxUtils.Position.RIGHT) { + svgMetrics.width -= flyoutMetrics.width; + } + } + return { + height: svgMetrics.height / scale, + width: svgMetrics.width / scale, + top: -this.workspace_.scrollY / scale, + left: -this.workspace_.scrollX / scale, + }; + } + + /** + * Gets content metrics in either pixel or workspace coordinates. + * The content area is a rectangle around all the top bounded elements on the + * workspace (workspace comments and blocks). + * @param {boolean=} opt_getWorkspaceCoordinates True to get the content + * metrics in workspace coordinates, false to get them in pixel + * coordinates. + * @return {!MetricsManager.ContainerRegion} The + * metrics for the content container. + * @public + */ + getContentMetrics(opt_getWorkspaceCoordinates) { + const scale = opt_getWorkspaceCoordinates ? 1 : this.workspace_.scale; + + // Block bounding box is in workspace coordinates. + const blockBox = this.workspace_.getBlocksBoundingBox(); + + return { + height: (blockBox.bottom - blockBox.top) * scale, + width: (blockBox.right - blockBox.left) * scale, + top: blockBox.top * scale, + left: blockBox.left * scale, + }; + } + + /** + * Returns whether the scroll area has fixed edges. + * @return {boolean} Whether the scroll area has fixed edges. + * @package + */ + hasFixedEdges() { + // This exists for optimization of bump logic. + return !this.workspace_.isMovableHorizontally() || + !this.workspace_.isMovableVertically(); + } + + /** + * Computes the fixed edges of the scroll area. + * @param {!MetricsManager.ContainerRegion=} opt_viewMetrics The view + * metrics if they have been previously computed. Passing in null may + * cause the view metrics to be computed again, if it is needed. + * @return {!MetricsManager.FixedEdges} The fixed edges of the scroll + * area. + * @protected + */ + getComputedFixedEdges_(opt_viewMetrics) { + if (!this.hasFixedEdges()) { + // Return early if there are no edges. + return {}; + } + + const hScrollEnabled = this.workspace_.isMovableHorizontally(); + const vScrollEnabled = this.workspace_.isMovableVertically(); + + const viewMetrics = opt_viewMetrics || this.getViewMetrics(false); + + const edges = {}; + if (!vScrollEnabled) { + edges.top = viewMetrics.top; + edges.bottom = viewMetrics.top + viewMetrics.height; + } + if (!hScrollEnabled) { + edges.left = viewMetrics.left; + edges.right = viewMetrics.left + viewMetrics.width; + } + return edges; + } + + /** + * Returns the content area with added padding. + * @param {!MetricsManager.ContainerRegion} viewMetrics The view + * metrics. + * @param {!MetricsManager.ContainerRegion} contentMetrics The content + * metrics. + * @return {{top: number, bottom: number, left: number, right: number}} The + * padded content area. + * @protected + */ + getPaddedContent_(viewMetrics, contentMetrics) { + const contentBottom = contentMetrics.top + contentMetrics.height; + const contentRight = contentMetrics.left + contentMetrics.width; + + const viewWidth = viewMetrics.width; + const viewHeight = viewMetrics.height; + const halfWidth = viewWidth / 2; + const halfHeight = viewHeight / 2; + + // Add a padding around the content that is at least half a screen wide. + // Ensure padding is wide enough that blocks can scroll over entire screen. + const top = + Math.min(contentMetrics.top - halfHeight, contentBottom - viewHeight); + const left = + Math.min(contentMetrics.left - halfWidth, contentRight - viewWidth); + const bottom = + Math.max(contentBottom + halfHeight, contentMetrics.top + viewHeight); + const right = + Math.max(contentRight + halfWidth, contentMetrics.left + viewWidth); + + return {top: top, bottom: bottom, left: left, right: right}; + } + + /** + * Returns the metrics for the scroll area of the workspace. + * @param {boolean=} opt_getWorkspaceCoordinates True to get the scroll + * metrics in workspace coordinates, false to get them in pixel + * coordinates. + * @param {!MetricsManager.ContainerRegion=} opt_viewMetrics The view + * metrics if they have been previously computed. Passing in null may + * cause the view metrics to be computed again, if it is needed. + * @param {!MetricsManager.ContainerRegion=} opt_contentMetrics The + * content metrics if they have been previously computed. Passing in null + * may cause the content metrics to be computed again, if it is needed. + * @return {!MetricsManager.ContainerRegion} The metrics for the scroll + * container. + */ + getScrollMetrics( + opt_getWorkspaceCoordinates, opt_viewMetrics, opt_contentMetrics) { + const scale = opt_getWorkspaceCoordinates ? this.workspace_.scale : 1; + const viewMetrics = opt_viewMetrics || this.getViewMetrics(false); + const contentMetrics = opt_contentMetrics || this.getContentMetrics(); + const fixedEdges = this.getComputedFixedEdges_(viewMetrics); + + // Add padding around content. + const paddedContent = this.getPaddedContent_(viewMetrics, contentMetrics); + + // Use combination of fixed bounds and padded content to make scroll area. + const top = + fixedEdges.top !== undefined ? fixedEdges.top : paddedContent.top; + const left = + fixedEdges.left !== undefined ? fixedEdges.left : paddedContent.left; + const bottom = fixedEdges.bottom !== undefined ? fixedEdges.bottom : + paddedContent.bottom; + const right = + fixedEdges.right !== undefined ? fixedEdges.right : paddedContent.right; + + return { + top: top / scale, + left: left / scale, + width: (right - left) / scale, + height: (bottom - top) / scale, + }; + } + + /** + * Returns common metrics used by UI elements. + * @return {!MetricsManager.UiMetrics} The UI metrics. + */ + getUiMetrics() { + return { + viewMetrics: this.getViewMetrics(), + absoluteMetrics: this.getAbsoluteMetrics(), + toolboxMetrics: this.getToolboxMetrics(), + }; + } + + /** + * Returns an object with all the metrics required to size scrollbars for a + * top level workspace. The following properties are computed: + * Coordinate system: pixel coordinates, -left, -up, +right, +down + * .viewHeight: Height of the visible portion of the workspace. + * .viewWidth: Width of the visible portion of the workspace. + * .contentHeight: Height of the content. + * .contentWidth: Width of the content. + * .scrollHeight: Height of the scroll area. + * .scrollWidth: Width of the scroll area. + * .svgHeight: Height of the Blockly div (the view + the toolbox, + * simple or otherwise), + * .svgWidth: Width of the Blockly div (the view + the toolbox, + * simple or otherwise), + * .viewTop: Top-edge of the visible portion of the workspace, relative to + * the workspace origin. + * .viewLeft: Left-edge of the visible portion of the workspace, relative to + * the workspace origin. + * .contentTop: Top-edge of the content, relative to the workspace origin. + * .contentLeft: Left-edge of the content relative to the workspace origin. + * .scrollTop: Top-edge of the scroll area, relative to the workspace origin. + * .scrollLeft: Left-edge of the scroll area relative to the workspace origin. + * .absoluteTop: Top-edge of the visible portion of the workspace, relative + * to the blocklyDiv. + * .absoluteLeft: Left-edge of the visible portion of the workspace, relative + * to the blocklyDiv. + * .toolboxWidth: Width of the toolbox, if it exists. Otherwise zero. + * .toolboxHeight: Height of the toolbox, if it exists. Otherwise zero. + * .flyoutWidth: Width of the flyout if it is always open. Otherwise zero. + * .flyoutHeight: Height of the flyout if it is always open. Otherwise zero. + * .toolboxPosition: Top, bottom, left or right. Use TOOLBOX_AT constants to + * compare. + * @return {!Metrics} Contains size and position metrics of a top + * level workspace. + * @public + */ + getMetrics() { + const toolboxMetrics = this.getToolboxMetrics(); + const flyoutMetrics = this.getFlyoutMetrics(true); + const svgMetrics = this.getSvgMetrics(); + const absoluteMetrics = this.getAbsoluteMetrics(); + const viewMetrics = this.getViewMetrics(); + const contentMetrics = this.getContentMetrics(); + const scrollMetrics = + this.getScrollMetrics(false, viewMetrics, contentMetrics); + + return { + contentHeight: contentMetrics.height, + contentWidth: contentMetrics.width, + contentTop: contentMetrics.top, + contentLeft: contentMetrics.left, + + scrollHeight: scrollMetrics.height, + scrollWidth: scrollMetrics.width, + scrollTop: scrollMetrics.top, + scrollLeft: scrollMetrics.left, + + viewHeight: viewMetrics.height, + viewWidth: viewMetrics.width, + viewTop: viewMetrics.top, + viewLeft: viewMetrics.left, + + absoluteTop: absoluteMetrics.top, + absoluteLeft: absoluteMetrics.left, + + svgHeight: svgMetrics.height, + svgWidth: svgMetrics.width, + + toolboxWidth: toolboxMetrics.width, + toolboxHeight: toolboxMetrics.height, + toolboxPosition: toolboxMetrics.position, + + flyoutWidth: flyoutMetrics.width, + flyoutHeight: flyoutMetrics.height, + }; + } +} /** * Describes the width, height and location of the toolbox on the main @@ -57,6 +446,7 @@ const MetricsManager = function(workspace) { * }} */ MetricsManager.ToolboxMetrics; + /** * Describes where the viewport starts in relation to the workspace SVG. * @typedef {{ @@ -65,8 +455,10 @@ MetricsManager.ToolboxMetrics; * }} */ MetricsManager.AbsoluteMetrics; + /** - * All the measurements needed to describe the size and location of a container. + * All the measurements needed to describe the size and location of a + * container. * @typedef {{ * height: number, * width: number, @@ -75,6 +467,7 @@ MetricsManager.AbsoluteMetrics; * }} */ MetricsManager.ContainerRegion; + /** * Describes fixed edges of the workspace. * @typedef {{ @@ -85,6 +478,7 @@ MetricsManager.ContainerRegion; * }} */ MetricsManager.FixedEdges; + /** * Common metrics used for UI elements. * @typedef {{ @@ -94,387 +488,6 @@ MetricsManager.FixedEdges; * }} */ MetricsManager.UiMetrics; -/** - * Gets the dimensions of the given workspace component, in pixel coordinates. - * @param {?IToolbox|?IFlyout} elem The element to get the - * dimensions of, or null. It should be a toolbox or flyout, and should - * implement getWidth() and getHeight(). - * @return {!Size} An object containing width and height - * attributes, which will both be zero if elem did not exist. - * @protected - */ -MetricsManager.prototype.getDimensionsPx_ = function(elem) { - let width = 0; - let height = 0; - if (elem) { - width = elem.getWidth(); - height = elem.getHeight(); - } - return new Size(width, height); -}; - -/** - * Gets the width and the height of the flyout on the workspace in pixel - * coordinates. Returns 0 for the width and height if the workspace has a - * category toolbox instead of a simple toolbox. - * @param {boolean=} opt_own Whether to only return the workspace's own flyout. - * @return {!MetricsManager.ToolboxMetrics} The width and height of the - * flyout. - * @public - */ -MetricsManager.prototype.getFlyoutMetrics = function(opt_own) { - const flyoutDimensions = - this.getDimensionsPx_(this.workspace_.getFlyout(opt_own)); - return { - width: flyoutDimensions.width, - height: flyoutDimensions.height, - position: this.workspace_.toolboxPosition, - }; -}; - -/** - * Gets the width, height and position of the toolbox on the workspace in pixel - * coordinates. Returns 0 for the width and height if the workspace has a simple - * toolbox instead of a category toolbox. To get the width and height of a - * simple toolbox @see {@link getFlyoutMetrics}. - * @return {!MetricsManager.ToolboxMetrics} The object with the width, - * height and position of the toolbox. - * @public - */ -MetricsManager.prototype.getToolboxMetrics = function() { - const toolboxDimensions = this.getDimensionsPx_(this.workspace_.getToolbox()); - - return { - width: toolboxDimensions.width, - height: toolboxDimensions.height, - position: this.workspace_.toolboxPosition, - }; -}; - -/** - * Gets the width and height of the workspace's parent SVG element in pixel - * coordinates. This area includes the toolbox and the visible workspace area. - * @return {!Size} The width and height of the workspace's parent - * SVG element. - * @public - */ -MetricsManager.prototype.getSvgMetrics = function() { - return this.workspace_.getCachedParentSvgSize(); -}; - -/** - * Gets the absolute left and absolute top in pixel coordinates. - * This is where the visible workspace starts in relation to the SVG container. - * @return {!MetricsManager.AbsoluteMetrics} The absolute metrics for - * the workspace. - * @public - */ -MetricsManager.prototype.getAbsoluteMetrics = function() { - let absoluteLeft = 0; - const toolboxMetrics = this.getToolboxMetrics(); - const flyoutMetrics = this.getFlyoutMetrics(true); - const doesToolboxExist = !!this.workspace_.getToolbox(); - const doesFlyoutExist = !!this.workspace_.getFlyout(true); - const toolboxPosition = - doesToolboxExist ? toolboxMetrics.position : flyoutMetrics.position; - - const atLeft = toolboxPosition === toolboxUtils.Position.LEFT; - const atTop = toolboxPosition === toolboxUtils.Position.TOP; - if (doesToolboxExist && atLeft) { - absoluteLeft = toolboxMetrics.width; - } else if (doesFlyoutExist && atLeft) { - absoluteLeft = flyoutMetrics.width; - } - let absoluteTop = 0; - if (doesToolboxExist && atTop) { - absoluteTop = toolboxMetrics.height; - } else if (doesFlyoutExist && atTop) { - absoluteTop = flyoutMetrics.height; - } - - return { - top: absoluteTop, - left: absoluteLeft, - }; -}; - -/** - * Gets the metrics for the visible workspace in either pixel or workspace - * coordinates. The visible workspace does not include the toolbox or flyout. - * @param {boolean=} opt_getWorkspaceCoordinates True to get the view metrics in - * workspace coordinates, false to get them in pixel coordinates. - * @return {!MetricsManager.ContainerRegion} The width, height, top and - * left of the viewport in either workspace coordinates or pixel - * coordinates. - * @public - */ -MetricsManager.prototype.getViewMetrics = function( - opt_getWorkspaceCoordinates) { - const scale = opt_getWorkspaceCoordinates ? this.workspace_.scale : 1; - const svgMetrics = this.getSvgMetrics(); - const toolboxMetrics = this.getToolboxMetrics(); - const flyoutMetrics = this.getFlyoutMetrics(true); - const doesToolboxExist = !!this.workspace_.getToolbox(); - const toolboxPosition = - doesToolboxExist ? toolboxMetrics.position : flyoutMetrics.position; - - if (this.workspace_.getToolbox()) { - if (toolboxPosition === toolboxUtils.Position.TOP || - toolboxPosition === toolboxUtils.Position.BOTTOM) { - svgMetrics.height -= toolboxMetrics.height; - } else if ( - toolboxPosition === toolboxUtils.Position.LEFT || - toolboxPosition === toolboxUtils.Position.RIGHT) { - svgMetrics.width -= toolboxMetrics.width; - } - } else if (this.workspace_.getFlyout(true)) { - if (toolboxPosition === toolboxUtils.Position.TOP || - toolboxPosition === toolboxUtils.Position.BOTTOM) { - svgMetrics.height -= flyoutMetrics.height; - } else if ( - toolboxPosition === toolboxUtils.Position.LEFT || - toolboxPosition === toolboxUtils.Position.RIGHT) { - svgMetrics.width -= flyoutMetrics.width; - } - } - return { - height: svgMetrics.height / scale, - width: svgMetrics.width / scale, - top: -this.workspace_.scrollY / scale, - left: -this.workspace_.scrollX / scale, - }; -}; - -/** - * Gets content metrics in either pixel or workspace coordinates. - * The content area is a rectangle around all the top bounded elements on the - * workspace (workspace comments and blocks). - * @param {boolean=} opt_getWorkspaceCoordinates True to get the content metrics - * in workspace coordinates, false to get them in pixel coordinates. - * @return {!MetricsManager.ContainerRegion} The - * metrics for the content container. - * @public - */ -MetricsManager.prototype.getContentMetrics = function( - opt_getWorkspaceCoordinates) { - const scale = opt_getWorkspaceCoordinates ? 1 : this.workspace_.scale; - - // Block bounding box is in workspace coordinates. - const blockBox = this.workspace_.getBlocksBoundingBox(); - - return { - height: (blockBox.bottom - blockBox.top) * scale, - width: (blockBox.right - blockBox.left) * scale, - top: blockBox.top * scale, - left: blockBox.left * scale, - }; -}; - -/** - * Returns whether the scroll area has fixed edges. - * @return {boolean} Whether the scroll area has fixed edges. - * @package - */ -MetricsManager.prototype.hasFixedEdges = function() { - // This exists for optimization of bump logic. - return !this.workspace_.isMovableHorizontally() || - !this.workspace_.isMovableVertically(); -}; - -/** - * Computes the fixed edges of the scroll area. - * @param {!MetricsManager.ContainerRegion=} opt_viewMetrics The view - * metrics if they have been previously computed. Passing in null may cause - * the view metrics to be computed again, if it is needed. - * @return {!MetricsManager.FixedEdges} The fixed edges of the scroll - * area. - * @protected - */ -MetricsManager.prototype.getComputedFixedEdges_ = function(opt_viewMetrics) { - if (!this.hasFixedEdges()) { - // Return early if there are no edges. - return {}; - } - - const hScrollEnabled = this.workspace_.isMovableHorizontally(); - const vScrollEnabled = this.workspace_.isMovableVertically(); - - const viewMetrics = opt_viewMetrics || this.getViewMetrics(false); - - const edges = {}; - if (!vScrollEnabled) { - edges.top = viewMetrics.top; - edges.bottom = viewMetrics.top + viewMetrics.height; - } - if (!hScrollEnabled) { - edges.left = viewMetrics.left; - edges.right = viewMetrics.left + viewMetrics.width; - } - return edges; -}; - -/** - * Returns the content area with added padding. - * @param {!MetricsManager.ContainerRegion} viewMetrics The view - * metrics. - * @param {!MetricsManager.ContainerRegion} contentMetrics The content - * metrics. - * @return {{top: number, bottom: number, left: number, right: number}} The - * padded content area. - * @protected - */ -MetricsManager.prototype.getPaddedContent_ = function( - viewMetrics, contentMetrics) { - const contentBottom = contentMetrics.top + contentMetrics.height; - const contentRight = contentMetrics.left + contentMetrics.width; - - const viewWidth = viewMetrics.width; - const viewHeight = viewMetrics.height; - const halfWidth = viewWidth / 2; - const halfHeight = viewHeight / 2; - - // Add a padding around the content that is at least half a screen wide. - // Ensure padding is wide enough that blocks can scroll over entire screen. - const top = - Math.min(contentMetrics.top - halfHeight, contentBottom - viewHeight); - const left = - Math.min(contentMetrics.left - halfWidth, contentRight - viewWidth); - const bottom = - Math.max(contentBottom + halfHeight, contentMetrics.top + viewHeight); - const right = - Math.max(contentRight + halfWidth, contentMetrics.left + viewWidth); - - return {top: top, bottom: bottom, left: left, right: right}; -}; - -/** - * Returns the metrics for the scroll area of the workspace. - * @param {boolean=} opt_getWorkspaceCoordinates True to get the scroll metrics - * in workspace coordinates, false to get them in pixel coordinates. - * @param {!MetricsManager.ContainerRegion=} opt_viewMetrics The view - * metrics if they have been previously computed. Passing in null may cause - * the view metrics to be computed again, if it is needed. - * @param {!MetricsManager.ContainerRegion=} opt_contentMetrics The - * content metrics if they have been previously computed. Passing in null - * may cause the content metrics to be computed again, if it is needed. - * @return {!MetricsManager.ContainerRegion} The metrics for the scroll - * container. - */ -MetricsManager.prototype.getScrollMetrics = function( - opt_getWorkspaceCoordinates, opt_viewMetrics, opt_contentMetrics) { - const scale = opt_getWorkspaceCoordinates ? this.workspace_.scale : 1; - const viewMetrics = opt_viewMetrics || this.getViewMetrics(false); - const contentMetrics = opt_contentMetrics || this.getContentMetrics(); - const fixedEdges = this.getComputedFixedEdges_(viewMetrics); - - // Add padding around content. - const paddedContent = this.getPaddedContent_(viewMetrics, contentMetrics); - - // Use combination of fixed bounds and padded content to make scroll area. - const top = fixedEdges.top !== undefined ? fixedEdges.top : paddedContent.top; - const left = - fixedEdges.left !== undefined ? fixedEdges.left : paddedContent.left; - const bottom = fixedEdges.bottom !== undefined ? fixedEdges.bottom : - paddedContent.bottom; - const right = - fixedEdges.right !== undefined ? fixedEdges.right : paddedContent.right; - - return { - top: top / scale, - left: left / scale, - width: (right - left) / scale, - height: (bottom - top) / scale, - }; -}; - -/** - * Returns common metrics used by UI elements. - * @return {!MetricsManager.UiMetrics} The UI metrics. - */ -MetricsManager.prototype.getUiMetrics = function() { - return { - viewMetrics: this.getViewMetrics(), - absoluteMetrics: this.getAbsoluteMetrics(), - toolboxMetrics: this.getToolboxMetrics(), - }; -}; - -/** - * Returns an object with all the metrics required to size scrollbars for a - * top level workspace. The following properties are computed: - * Coordinate system: pixel coordinates, -left, -up, +right, +down - * .viewHeight: Height of the visible portion of the workspace. - * .viewWidth: Width of the visible portion of the workspace. - * .contentHeight: Height of the content. - * .contentWidth: Width of the content. - * .scrollHeight: Height of the scroll area. - * .scrollWidth: Width of the scroll area. - * .svgHeight: Height of the Blockly div (the view + the toolbox, - * simple or otherwise), - * .svgWidth: Width of the Blockly div (the view + the toolbox, - * simple or otherwise), - * .viewTop: Top-edge of the visible portion of the workspace, relative to - * the workspace origin. - * .viewLeft: Left-edge of the visible portion of the workspace, relative to - * the workspace origin. - * .contentTop: Top-edge of the content, relative to the workspace origin. - * .contentLeft: Left-edge of the content relative to the workspace origin. - * .scrollTop: Top-edge of the scroll area, relative to the workspace origin. - * .scrollLeft: Left-edge of the scroll area relative to the workspace origin. - * .absoluteTop: Top-edge of the visible portion of the workspace, relative - * to the blocklyDiv. - * .absoluteLeft: Left-edge of the visible portion of the workspace, relative - * to the blocklyDiv. - * .toolboxWidth: Width of the toolbox, if it exists. Otherwise zero. - * .toolboxHeight: Height of the toolbox, if it exists. Otherwise zero. - * .flyoutWidth: Width of the flyout if it is always open. Otherwise zero. - * .flyoutHeight: Height of the flyout if it is always open. Otherwise zero. - * .toolboxPosition: Top, bottom, left or right. Use TOOLBOX_AT constants to - * compare. - * @return {!Metrics} Contains size and position metrics of a top - * level workspace. - * @public - */ -MetricsManager.prototype.getMetrics = function() { - const toolboxMetrics = this.getToolboxMetrics(); - const flyoutMetrics = this.getFlyoutMetrics(true); - const svgMetrics = this.getSvgMetrics(); - const absoluteMetrics = this.getAbsoluteMetrics(); - const viewMetrics = this.getViewMetrics(); - const contentMetrics = this.getContentMetrics(); - const scrollMetrics = - this.getScrollMetrics(false, viewMetrics, contentMetrics); - - return { - contentHeight: contentMetrics.height, - contentWidth: contentMetrics.width, - contentTop: contentMetrics.top, - contentLeft: contentMetrics.left, - - scrollHeight: scrollMetrics.height, - scrollWidth: scrollMetrics.width, - scrollTop: scrollMetrics.top, - scrollLeft: scrollMetrics.left, - - viewHeight: viewMetrics.height, - viewWidth: viewMetrics.width, - viewTop: viewMetrics.top, - viewLeft: viewMetrics.left, - - absoluteTop: absoluteMetrics.top, - absoluteLeft: absoluteMetrics.left, - - svgHeight: svgMetrics.height, - svgWidth: svgMetrics.width, - - toolboxWidth: toolboxMetrics.width, - toolboxHeight: toolboxMetrics.height, - toolboxPosition: toolboxMetrics.position, - - flyoutWidth: flyoutMetrics.width, - flyoutHeight: flyoutMetrics.height, - }; -}; registry.register( registry.Type.METRICS_MANAGER, registry.DEFAULT, MetricsManager); diff --git a/core/touch_gesture.js b/core/touch_gesture.js index 815da7c63..c5b2396cc 100644 --- a/core/touch_gesture.js +++ b/core/touch_gesture.js @@ -19,7 +19,6 @@ goog.module('Blockly.TouchGesture'); const Touch = goog.require('Blockly.Touch'); const browserEvents = goog.require('Blockly.browserEvents'); -const object = goog.require('Blockly.utils.object'); const {Coordinate} = goog.require('Blockly.utils.Coordinate'); const {Gesture} = goog.require('Blockly.Gesture'); /* eslint-disable-next-line no-unused-vars */ @@ -31,300 +30,304 @@ const {WorkspaceSvg} = goog.requireType('Blockly.WorkspaceSvg'); * events. "End" refers to touchend, mouseup, and pointerend events. */ -/** - * Class for one gesture. - * @param {!Event} e The event that kicked off this gesture. - * @param {!WorkspaceSvg} creatorWorkspace The workspace that created - * this gesture and has a reference to it. - * @extends {Gesture} - * @constructor - * @alias Blockly.TouchGesture - */ -const TouchGesture = function(e, creatorWorkspace) { - TouchGesture.superClass_.constructor.call(this, e, creatorWorkspace); - - /** - * Boolean for whether or not this gesture is a multi-touch gesture. - * @type {boolean} - * @private - */ - this.isMultiTouch_ = false; - - /** - * A map of cached points used for tracking multi-touch gestures. - * @type {!Object} - * @private - */ - this.cachedPoints_ = Object.create(null); - - /** - * This is the ratio between the starting distance between the touch points - * and the most recent distance between the touch points. - * Scales between 0 and 1 mean the most recent zoom was a zoom out. - * Scales above 1.0 mean the most recent zoom was a zoom in. - * @type {number} - * @private - */ - this.previousScale_ = 0; - - /** - * The starting distance between two touch points. - * @type {number} - * @private - */ - this.startDistance_ = 0; - - /** - * A handle to use to unbind the second touch start or pointer down listener - * at the end of a drag. - * Opaque data returned from Blockly.bindEventWithChecks_. - * @type {?browserEvents.Data} - * @private - */ - this.onStartWrapper_ = null; - - /** - * Boolean for whether or not the workspace supports pinch-zoom. - * @type {?boolean} - * @private - */ - this.isPinchZoomEnabled_ = null; -}; -object.inherits(TouchGesture, Gesture); /** * A multiplier used to convert the gesture scale to a zoom in delta. * @const */ -TouchGesture.ZOOM_IN_MULTIPLIER = 5; +const ZOOM_IN_MULTIPLIER = 5; /** * A multiplier used to convert the gesture scale to a zoom out delta. * @const */ -TouchGesture.ZOOM_OUT_MULTIPLIER = 6; +const ZOOM_OUT_MULTIPLIER = 6; /** - * Start a gesture: update the workspace to indicate that a gesture is in - * progress and bind mousemove and mouseup handlers. - * @param {!Event} e A mouse down, touch start or pointer down event. - * @package + * Class for one gesture. + * @extends {Gesture} */ -TouchGesture.prototype.doStart = function(e) { - this.isPinchZoomEnabled_ = this.startWorkspace_.options.zoomOptions && - this.startWorkspace_.options.zoomOptions.pinch; - TouchGesture.superClass_.doStart.call(this, e); - if (!this.isEnding_ && Touch.isTouchEvent(e)) { - this.handleTouchStart(e); +class TouchGesture extends Gesture { + /** + * @param {!Event} e The event that kicked off this gesture. + * @param {!WorkspaceSvg} creatorWorkspace The workspace that created + * this gesture and has a reference to it. + * @alias Blockly.TouchGesture + */ + constructor(e, creatorWorkspace) { + super(e, creatorWorkspace); + + /** + * Boolean for whether or not this gesture is a multi-touch gesture. + * @type {boolean} + * @private + */ + this.isMultiTouch_ = false; + + /** + * A map of cached points used for tracking multi-touch gestures. + * @type {!Object} + * @private + */ + this.cachedPoints_ = Object.create(null); + + /** + * This is the ratio between the starting distance between the touch points + * and the most recent distance between the touch points. + * Scales between 0 and 1 mean the most recent zoom was a zoom out. + * Scales above 1.0 mean the most recent zoom was a zoom in. + * @type {number} + * @private + */ + this.previousScale_ = 0; + + /** + * The starting distance between two touch points. + * @type {number} + * @private + */ + this.startDistance_ = 0; + + /** + * A handle to use to unbind the second touch start or pointer down listener + * at the end of a drag. + * Opaque data returned from Blockly.bindEventWithChecks_. + * @type {?browserEvents.Data} + * @private + */ + this.onStartWrapper_ = null; + + /** + * Boolean for whether or not the workspace supports pinch-zoom. + * @type {?boolean} + * @private + */ + this.isPinchZoomEnabled_ = null; } -}; -/** - * Bind gesture events. - * Overriding the gesture definition of this function, binding the same - * functions for onMoveWrapper_ and onUpWrapper_ but passing - * opt_noCaptureIdentifier. - * In addition, binding a second mouse down event to detect multi-touch events. - * @param {!Event} e A mouse down or touch start event. - * @package - */ -TouchGesture.prototype.bindMouseEvents = function(e) { - this.onStartWrapper_ = browserEvents.conditionalBind( - document, 'mousedown', null, this.handleStart.bind(this), - /* opt_noCaptureIdentifier */ true); - this.onMoveWrapper_ = browserEvents.conditionalBind( - document, 'mousemove', null, this.handleMove.bind(this), - /* opt_noCaptureIdentifier */ true); - this.onUpWrapper_ = browserEvents.conditionalBind( - document, 'mouseup', null, this.handleUp.bind(this), - /* opt_noCaptureIdentifier */ true); - - e.preventDefault(); - e.stopPropagation(); -}; - -/** - * Handle a mouse down, touch start, or pointer down event. - * @param {!Event} e A mouse down, touch start, or pointer down event. - * @package - */ -TouchGesture.prototype.handleStart = function(e) { - if (this.isDragging()) { - // A drag has already started, so this can no longer be a pinch-zoom. - return; - } - if (Touch.isTouchEvent(e)) { - this.handleTouchStart(e); - - if (this.isMultiTouch()) { - Touch.longStop(); + /** + * Start a gesture: update the workspace to indicate that a gesture is in + * progress and bind mousemove and mouseup handlers. + * @param {!Event} e A mouse down, touch start or pointer down event. + * @package + */ + doStart(e) { + this.isPinchZoomEnabled_ = this.startWorkspace_.options.zoomOptions && + this.startWorkspace_.options.zoomOptions.pinch; + super.doStart(e); + if (!this.isEnding_ && Touch.isTouchEvent(e)) { + this.handleTouchStart(e); } } -}; -/** - * Handle a mouse move, touch move, or pointer move event. - * @param {!Event} e A mouse move, touch move, or pointer move event. - * @package - */ -TouchGesture.prototype.handleMove = function(e) { - if (this.isDragging()) { - // We are in the middle of a drag, only handle the relevant events - if (Touch.shouldHandleEvent(e)) { - TouchGesture.superClass_.handleMove.call(this, e); - } - return; - } - if (this.isMultiTouch()) { - if (Touch.isTouchEvent(e)) { - this.handleTouchMove(e); - } - Touch.longStop(); - } else { - TouchGesture.superClass_.handleMove.call(this, e); - } -}; + /** + * Bind gesture events. + * Overriding the gesture definition of this function, binding the same + * functions for onMoveWrapper_ and onUpWrapper_ but passing + * opt_noCaptureIdentifier. + * In addition, binding a second mouse down event to detect multi-touch + * events. + * @param {!Event} e A mouse down or touch start event. + * @package + */ + bindMouseEvents(e) { + this.onStartWrapper_ = browserEvents.conditionalBind( + document, 'mousedown', null, this.handleStart.bind(this), + /* opt_noCaptureIdentifier */ true); + this.onMoveWrapper_ = browserEvents.conditionalBind( + document, 'mousemove', null, this.handleMove.bind(this), + /* opt_noCaptureIdentifier */ true); + this.onUpWrapper_ = browserEvents.conditionalBind( + document, 'mouseup', null, this.handleUp.bind(this), + /* opt_noCaptureIdentifier */ true); -/** - * Handle a mouse up, touch end, or pointer up event. - * @param {!Event} e A mouse up, touch end, or pointer up event. - * @package - */ -TouchGesture.prototype.handleUp = function(e) { - if (Touch.isTouchEvent(e) && !this.isDragging()) { - this.handleTouchEnd(e); - } - if (!this.isMultiTouch() || this.isDragging()) { - if (!Touch.shouldHandleEvent(e)) { - return; - } - TouchGesture.superClass_.handleUp.call(this, e); - } else { e.preventDefault(); e.stopPropagation(); - - this.dispose(); } -}; -/** - * Whether this gesture is part of a multi-touch gesture. - * @return {boolean} Whether this gesture is part of a multi-touch gesture. - * @package - */ -TouchGesture.prototype.isMultiTouch = function() { - return this.isMultiTouch_; -}; + /** + * Handle a mouse down, touch start, or pointer down event. + * @param {!Event} e A mouse down, touch start, or pointer down event. + * @package + */ + handleStart(e) { + if (this.isDragging()) { + // A drag has already started, so this can no longer be a pinch-zoom. + return; + } + if (Touch.isTouchEvent(e)) { + this.handleTouchStart(e); -/** - * Sever all links from this object. - * @package - */ -TouchGesture.prototype.dispose = function() { - TouchGesture.superClass_.dispose.call(this); - - if (this.onStartWrapper_) { - browserEvents.unbind(this.onStartWrapper_); + if (this.isMultiTouch()) { + Touch.longStop(); + } + } } -}; -/** - * Handle a touch start or pointer down event and keep track of current - * pointers. - * @param {!Event} e A touch start, or pointer down event. - * @package - */ -TouchGesture.prototype.handleTouchStart = function(e) { - const pointerId = Touch.getTouchIdentifierFromEvent(e); - // store the pointerId in the current list of pointers - this.cachedPoints_[pointerId] = this.getTouchPoint(e); - const pointers = Object.keys(this.cachedPoints_); - // If two pointers are down, store info - if (pointers.length === 2) { + /** + * Handle a mouse move, touch move, or pointer move event. + * @param {!Event} e A mouse move, touch move, or pointer move event. + * @package + */ + handleMove(e) { + if (this.isDragging()) { + // We are in the middle of a drag, only handle the relevant events + if (Touch.shouldHandleEvent(e)) { + super.handleMove(e); + } + return; + } + if (this.isMultiTouch()) { + if (Touch.isTouchEvent(e)) { + this.handleTouchMove(e); + } + Touch.longStop(); + } else { + super.handleMove(e); + } + } + + /** + * Handle a mouse up, touch end, or pointer up event. + * @param {!Event} e A mouse up, touch end, or pointer up event. + * @package + */ + handleUp(e) { + if (Touch.isTouchEvent(e) && !this.isDragging()) { + this.handleTouchEnd(e); + } + if (!this.isMultiTouch() || this.isDragging()) { + if (!Touch.shouldHandleEvent(e)) { + return; + } + super.handleUp(e); + } else { + e.preventDefault(); + e.stopPropagation(); + + this.dispose(); + } + } + + /** + * Whether this gesture is part of a multi-touch gesture. + * @return {boolean} Whether this gesture is part of a multi-touch gesture. + * @package + */ + isMultiTouch() { + return this.isMultiTouch_; + } + + /** + * Sever all links from this object. + * @package + */ + dispose() { + super.dispose(); + + if (this.onStartWrapper_) { + browserEvents.unbind(this.onStartWrapper_); + } + } + + /** + * Handle a touch start or pointer down event and keep track of current + * pointers. + * @param {!Event} e A touch start, or pointer down event. + * @package + */ + handleTouchStart(e) { + const pointerId = Touch.getTouchIdentifierFromEvent(e); + // store the pointerId in the current list of pointers + this.cachedPoints_[pointerId] = this.getTouchPoint(e); + const pointers = Object.keys(this.cachedPoints_); + // If two pointers are down, store info + if (pointers.length === 2) { + const point0 = + /** @type {!Coordinate} */ (this.cachedPoints_[pointers[0]]); + const point1 = + /** @type {!Coordinate} */ (this.cachedPoints_[pointers[1]]); + this.startDistance_ = Coordinate.distance(point0, point1); + this.isMultiTouch_ = true; + e.preventDefault(); + } + } + + /** + * Handle a touch move or pointer move event and zoom in/out if two pointers + * are on the screen. + * @param {!Event} e A touch move, or pointer move event. + * @package + */ + handleTouchMove(e) { + const pointerId = Touch.getTouchIdentifierFromEvent(e); + // Update the cache + this.cachedPoints_[pointerId] = this.getTouchPoint(e); + + const pointers = Object.keys(this.cachedPoints_); + if (this.isPinchZoomEnabled_ && pointers.length === 2) { + this.handlePinch_(e); + } else { + super.handleMove(e); + } + } + + /** + * Handle pinch zoom gesture. + * @param {!Event} e A touch move, or pointer move event. + * @private + */ + handlePinch_(e) { + const pointers = Object.keys(this.cachedPoints_); + // Calculate the distance between the two pointers const point0 = /** @type {!Coordinate} */ (this.cachedPoints_[pointers[0]]); const point1 = /** @type {!Coordinate} */ (this.cachedPoints_[pointers[1]]); - this.startDistance_ = Coordinate.distance(point0, point1); - this.isMultiTouch_ = true; + const moveDistance = Coordinate.distance(point0, point1); + const scale = moveDistance / this.startDistance_; + + if (this.previousScale_ > 0 && this.previousScale_ < Infinity) { + const gestureScale = scale - this.previousScale_; + const delta = gestureScale > 0 ? gestureScale * ZOOM_IN_MULTIPLIER : + gestureScale * ZOOM_OUT_MULTIPLIER; + const workspace = this.startWorkspace_; + const position = browserEvents.mouseToSvg( + e, workspace.getParentSvg(), workspace.getInverseScreenCTM()); + workspace.zoom(position.x, position.y, delta); + } + this.previousScale_ = scale; e.preventDefault(); } -}; -/** - * Handle a touch move or pointer move event and zoom in/out if two pointers - * are on the screen. - * @param {!Event} e A touch move, or pointer move event. - * @package - */ -TouchGesture.prototype.handleTouchMove = function(e) { - const pointerId = Touch.getTouchIdentifierFromEvent(e); - // Update the cache - this.cachedPoints_[pointerId] = this.getTouchPoint(e); - - const pointers = Object.keys(this.cachedPoints_); - if (this.isPinchZoomEnabled_ && pointers.length === 2) { - this.handlePinch_(e); - } else { - TouchGesture.superClass_.handleMove.call(this, e); + /** + * Handle a touch end or pointer end event and end the gesture. + * @param {!Event} e A touch end, or pointer end event. + * @package + */ + handleTouchEnd(e) { + const pointerId = Touch.getTouchIdentifierFromEvent(e); + if (this.cachedPoints_[pointerId]) { + delete this.cachedPoints_[pointerId]; + } + if (Object.keys(this.cachedPoints_).length < 2) { + this.cachedPoints_ = Object.create(null); + this.previousScale_ = 0; + } } -}; -/** - * Handle pinch zoom gesture. - * @param {!Event} e A touch move, or pointer move event. - * @private - */ -TouchGesture.prototype.handlePinch_ = function(e) { - const pointers = Object.keys(this.cachedPoints_); - // Calculate the distance between the two pointers - const point0 = /** @type {!Coordinate} */ (this.cachedPoints_[pointers[0]]); - const point1 = /** @type {!Coordinate} */ (this.cachedPoints_[pointers[1]]); - const moveDistance = Coordinate.distance(point0, point1); - const scale = moveDistance / this.startDistance_; - - if (this.previousScale_ > 0 && this.previousScale_ < Infinity) { - const gestureScale = scale - this.previousScale_; - const delta = gestureScale > 0 ? - gestureScale * TouchGesture.ZOOM_IN_MULTIPLIER : - gestureScale * TouchGesture.ZOOM_OUT_MULTIPLIER; - const workspace = this.startWorkspace_; - const position = browserEvents.mouseToSvg( - e, workspace.getParentSvg(), workspace.getInverseScreenCTM()); - workspace.zoom(position.x, position.y, delta); + /** + * Helper function returning the current touch point coordinate. + * @param {!Event} e A touch or pointer event. + * @return {?Coordinate} The current touch point coordinate + * @package + */ + getTouchPoint(e) { + if (!this.startWorkspace_) { + return null; + } + return new Coordinate( + (e.changedTouches ? e.changedTouches[0].pageX : e.pageX), + (e.changedTouches ? e.changedTouches[0].pageY : e.pageY)); } - this.previousScale_ = scale; - e.preventDefault(); -}; - - -/** - * Handle a touch end or pointer end event and end the gesture. - * @param {!Event} e A touch end, or pointer end event. - * @package - */ -TouchGesture.prototype.handleTouchEnd = function(e) { - const pointerId = Touch.getTouchIdentifierFromEvent(e); - if (this.cachedPoints_[pointerId]) { - delete this.cachedPoints_[pointerId]; - } - if (Object.keys(this.cachedPoints_).length < 2) { - this.cachedPoints_ = Object.create(null); - this.previousScale_ = 0; - } -}; - -/** - * Helper function returning the current touch point coordinate. - * @param {!Event} e A touch or pointer event. - * @return {?Coordinate} The current touch point coordinate - * @package - */ -TouchGesture.prototype.getTouchPoint = function(e) { - if (!this.startWorkspace_) { - return null; - } - return new Coordinate( - (e.changedTouches ? e.changedTouches[0].pageX : e.pageX), - (e.changedTouches ? e.changedTouches[0].pageY : e.pageY)); -}; +} exports.TouchGesture = TouchGesture; diff --git a/core/trashcan.js b/core/trashcan.js index 3fa25e433..3b7dbafc8 100644 --- a/core/trashcan.js +++ b/core/trashcan.js @@ -23,7 +23,6 @@ const browserEvents = goog.require('Blockly.browserEvents'); const dom = goog.require('Blockly.utils.dom'); const eventUtils = goog.require('Blockly.Events.utils'); const internalConstants = goog.require('Blockly.internalConstants'); -const object = goog.require('Blockly.utils.object'); const registry = goog.require('Blockly.registry'); const toolbox = goog.require('Blockly.utils.toolbox'); const uiPosition = goog.require('Blockly.uiPosition'); @@ -53,146 +52,631 @@ goog.require('Blockly.Events.TrashcanOpen'); /** * Class for a trash can. - * @param {!WorkspaceSvg} workspace The workspace to sit in. - * @constructor * @implements {IAutoHideable} * @implements {IPositionable} * @extends {DeleteArea} - * @alias Blockly.Trashcan */ -const Trashcan = function(workspace) { - Trashcan.superClass_.constructor.call(this); +class Trashcan extends DeleteArea { /** - * The workspace the trashcan sits in. - * @type {!WorkspaceSvg} + * @param {!WorkspaceSvg} workspace The workspace to sit in. + * @alias Blockly.Trashcan + */ + constructor(workspace) { + super(); + /** + * The workspace the trashcan sits in. + * @type {!WorkspaceSvg} + * @private + */ + this.workspace_ = workspace; + + /** + * The unique id for this component that is used to register with the + * ComponentManager. + * @type {string} + */ + this.id = 'trashcan'; + + /** + * A list of JSON (stored as strings) representing blocks in the trashcan. + * @type {!Array} + * @private + */ + this.contents_ = []; + + /** + * The trashcan flyout. + * @type {IFlyout} + * @package + */ + this.flyout = null; + + if (this.workspace_.options.maxTrashcanContents <= 0) { + return; + } + + /** + * Current open/close state of the lid. + * @type {boolean} + */ + this.isLidOpen = false; + + /** + * The minimum openness of the lid. Used to indicate if the trashcan + * contains blocks. + * @type {number} + * @private + */ + this.minOpenness_ = 0; + + /** + * The SVG group containing the trash can. + * @type {SVGElement} + * @private + */ + this.svgGroup_ = null; + + /** + * The SVG image element of the trash can lid. + * @type {SVGElement} + * @private + */ + this.svgLid_ = null; + + /** + * Task ID of opening/closing animation. + * @type {number} + * @private + */ + this.lidTask_ = 0; + + /** + * Current state of lid opening (0.0 = closed, 1.0 = open). + * @type {number} + * @private + */ + this.lidOpen_ = 0; + + /** + * Left coordinate of the trash can. + * @type {number} + * @private + */ + this.left_ = 0; + + /** + * Top coordinate of the trash can. + * @type {number} + * @private + */ + this.top_ = 0; + + /** + * Whether this trash can has been initialized. + * @type {boolean} + * @private + */ + this.initialized_ = false; + + // Create flyout options. + const flyoutWorkspaceOptions = new Options( + /** @type {!BlocklyOptions} */ + ({ + 'scrollbars': true, + 'parentWorkspace': this.workspace_, + 'rtl': this.workspace_.RTL, + 'oneBasedIndex': this.workspace_.options.oneBasedIndex, + 'renderer': this.workspace_.options.renderer, + 'rendererOverrides': this.workspace_.options.rendererOverrides, + 'move': { + 'scrollbars': true, + }, + })); + // Create vertical or horizontal flyout. + if (this.workspace_.horizontalLayout) { + flyoutWorkspaceOptions.toolboxPosition = + this.workspace_.toolboxPosition === toolbox.Position.TOP ? + toolbox.Position.BOTTOM : + toolbox.Position.TOP; + const HorizontalFlyout = registry.getClassFromOptions( + registry.Type.FLYOUTS_HORIZONTAL_TOOLBOX, this.workspace_.options, + true); + this.flyout = new HorizontalFlyout(flyoutWorkspaceOptions); + } else { + flyoutWorkspaceOptions.toolboxPosition = + this.workspace_.toolboxPosition === toolbox.Position.RIGHT ? + toolbox.Position.LEFT : + toolbox.Position.RIGHT; + const VerticalFlyout = registry.getClassFromOptions( + registry.Type.FLYOUTS_VERTICAL_TOOLBOX, this.workspace_.options, + true); + this.flyout = new VerticalFlyout(flyoutWorkspaceOptions); + } + this.workspace_.addChangeListener(this.onDelete_.bind(this)); + } + + /** + * Create the trash can elements. + * @return {!SVGElement} The trash can's SVG group. + */ + createDom() { + /* Here's the markup that will be generated: + + + + + + + + + + + */ + this.svgGroup_ = + dom.createSvgElement(Svg.G, {'class': 'blocklyTrash'}, null); + let clip; + const rnd = String(Math.random()).substring(2); + clip = dom.createSvgElement( + Svg.CLIPPATH, {'id': 'blocklyTrashBodyClipPath' + rnd}, this.svgGroup_); + dom.createSvgElement( + Svg.RECT, {'width': WIDTH, 'height': BODY_HEIGHT, 'y': LID_HEIGHT}, + clip); + const body = dom.createSvgElement( + Svg.IMAGE, { + 'width': internalConstants.SPRITE.width, + 'x': -SPRITE_LEFT, + 'height': internalConstants.SPRITE.height, + 'y': -SPRITE_TOP, + 'clip-path': 'url(#blocklyTrashBodyClipPath' + rnd + ')', + }, + this.svgGroup_); + body.setAttributeNS( + dom.XLINK_NS, 'xlink:href', + this.workspace_.options.pathToMedia + internalConstants.SPRITE.url); + + clip = dom.createSvgElement( + Svg.CLIPPATH, {'id': 'blocklyTrashLidClipPath' + rnd}, this.svgGroup_); + dom.createSvgElement( + Svg.RECT, {'width': WIDTH, 'height': LID_HEIGHT}, clip); + this.svgLid_ = dom.createSvgElement( + Svg.IMAGE, { + 'width': internalConstants.SPRITE.width, + 'x': -SPRITE_LEFT, + 'height': internalConstants.SPRITE.height, + 'y': -SPRITE_TOP, + 'clip-path': 'url(#blocklyTrashLidClipPath' + rnd + ')', + }, + this.svgGroup_); + this.svgLid_.setAttributeNS( + dom.XLINK_NS, 'xlink:href', + this.workspace_.options.pathToMedia + internalConstants.SPRITE.url); + + // bindEventWithChecks_ quashes events too aggressively. See: + // https://groups.google.com/forum/#!topic/blockly/QF4yB9Wx00s + // Using bindEventWithChecks_ for blocking mousedown causes issue in mobile. + // See #4303 + browserEvents.bind( + this.svgGroup_, 'mousedown', this, this.blockMouseDownWhenOpenable_); + browserEvents.bind(this.svgGroup_, 'mouseup', this, this.click); + // Bind to body instead of this.svgGroup_ so that we don't get lid jitters + browserEvents.bind(body, 'mouseover', this, this.mouseOver_); + browserEvents.bind(body, 'mouseout', this, this.mouseOut_); + this.animateLid_(); + return this.svgGroup_; + } + + /** + * Initializes the trash can. + */ + init() { + if (this.workspace_.options.maxTrashcanContents > 0) { + dom.insertAfter( + this.flyout.createDom(Svg.SVG), this.workspace_.getParentSvg()); + this.flyout.init(this.workspace_); + } + this.workspace_.getComponentManager().addComponent({ + component: this, + weight: 1, + capabilities: [ + ComponentManager.Capability.AUTOHIDEABLE, + ComponentManager.Capability.DELETE_AREA, + ComponentManager.Capability.DRAG_TARGET, + ComponentManager.Capability.POSITIONABLE, + ], + }); + this.initialized_ = true; + this.setLidOpen(false); + } + + /** + * Dispose of this trash can. + * Unlink from all DOM elements to prevent memory leaks. + * @suppress {checkTypes} + */ + dispose() { + this.workspace_.getComponentManager().removeComponent('trashcan'); + if (this.svgGroup_) { + dom.removeNode(this.svgGroup_); + this.svgGroup_ = null; + } + this.svgLid_ = null; + this.workspace_ = null; + clearTimeout(this.lidTask_); + } + + /** + * Whether the trashcan has contents. + * @return {boolean} True if the trashcan has contents. * @private */ - this.workspace_ = workspace; + hasContents_() { + return !!this.contents_.length; + } /** - * The unique id for this component that is used to register with the - * ComponentManager. - * @type {string} + * Returns true if the trashcan contents-flyout is currently open. + * @return {boolean} True if the trashcan contents-flyout is currently open. */ - this.id = 'trashcan'; + contentsIsOpen() { + return !!this.flyout && this.flyout.isVisible(); + } /** - * A list of JSON (stored as strings) representing blocks in the trashcan. - * @type {!Array} - * @private + * Opens the trashcan flyout. */ - this.contents_ = []; + openFlyout() { + if (this.contentsIsOpen()) { + return; + } + const contents = this.contents_.map(function(string) { + return JSON.parse(string); + }); + this.flyout.show(contents); + this.fireUiEvent_(true); + } /** - * The trashcan flyout. - * @type {IFlyout} + * Closes the trashcan flyout. + */ + closeFlyout() { + if (!this.contentsIsOpen()) { + return; + } + this.flyout.hide(); + this.fireUiEvent_(false); + this.workspace_.recordDragTargets(); + } + + /** + * Hides the component. Called in WorkspaceSvg.hideChaff. + * @param {boolean} onlyClosePopups Whether only popups should be closed. + * Flyouts should not be closed if this is true. + */ + autoHide(onlyClosePopups) { + // For now the trashcan flyout always autocloses because it overlays the + // trashcan UI (no trashcan to click to close it). + if (!onlyClosePopups && this.flyout) { + this.closeFlyout(); + } + } + + /** + * Empties the trashcan's contents. If the contents-flyout is currently open + * it will be closed. + */ + emptyContents() { + if (!this.hasContents_()) { + return; + } + this.contents_.length = 0; + this.setMinOpenness_(0); + this.closeFlyout(); + } + + /** + * Positions the trashcan. + * It is positioned in the opposite corner to the corner the + * categories/toolbox starts at. + * @param {!MetricsManager.UiMetrics} metrics The workspace metrics. + * @param {!Array} savedPositions List of rectangles that + * are already on the workspace. + */ + position(metrics, savedPositions) { + // Not yet initialized. + if (!this.initialized_) { + return; + } + + const cornerPosition = + uiPosition.getCornerOppositeToolbox(this.workspace_, metrics); + + const height = BODY_HEIGHT + LID_HEIGHT; + const startRect = uiPosition.getStartPositionRect( + cornerPosition, new Size(WIDTH, height), MARGIN_HORIZONTAL, + MARGIN_VERTICAL, metrics, this.workspace_); + + const verticalPosition = cornerPosition.vertical; + const bumpDirection = verticalPosition === uiPosition.verticalPosition.TOP ? + uiPosition.bumpDirection.DOWN : + uiPosition.bumpDirection.UP; + const positionRect = uiPosition.bumpPositionRect( + startRect, MARGIN_VERTICAL, bumpDirection, savedPositions); + + this.top_ = positionRect.top; + this.left_ = positionRect.left; + this.svgGroup_.setAttribute( + 'transform', 'translate(' + this.left_ + ',' + this.top_ + ')'); + } + + /** + * Returns the bounding rectangle of the UI element in pixel units relative to + * the Blockly injection div. + * @return {?Rect} The UI elements's bounding box. Null if + * bounding box should be ignored by other UI elements. + */ + getBoundingRectangle() { + const bottom = this.top_ + BODY_HEIGHT + LID_HEIGHT; + const right = this.left_ + WIDTH; + return new Rect(this.top_, bottom, this.left_, right); + } + + /** + * Returns the bounding rectangle of the drag target area in pixel units + * relative to viewport. + * @return {?Rect} The component's bounding box. Null if drag + * target area should be ignored. + */ + getClientRect() { + if (!this.svgGroup_) { + return null; + } + + const trashRect = this.svgGroup_.getBoundingClientRect(); + const top = trashRect.top + SPRITE_TOP - MARGIN_HOTSPOT; + const bottom = top + LID_HEIGHT + BODY_HEIGHT + 2 * MARGIN_HOTSPOT; + const left = trashRect.left + SPRITE_LEFT - MARGIN_HOTSPOT; + const right = left + WIDTH + 2 * MARGIN_HOTSPOT; + return new Rect(top, bottom, left, right); + } + + /** + * Handles when a cursor with a block or bubble is dragged over this drag + * target. + * @param {!IDraggable} _dragElement The block or bubble currently being + * dragged. + * @override + */ + onDragOver(_dragElement) { + this.setLidOpen(this.wouldDelete_); + } + + /** + * Handles when a cursor with a block or bubble exits this drag target. + * @param {!IDraggable} _dragElement The block or bubble currently being + * dragged. + * @override + */ + onDragExit(_dragElement) { + this.setLidOpen(false); + } + + /** + * Handles when a block or bubble is dropped on this component. + * Should not handle delete here. + * @param {!IDraggable} _dragElement The block or bubble currently being + * dragged. + * @override + */ + onDrop(_dragElement) { + setTimeout(this.setLidOpen.bind(this, false), 100); + } + + /** + * Flip the lid open or shut. + * @param {boolean} state True if open. * @package */ - this.flyout = null; - - if (this.workspace_.options.maxTrashcanContents <= 0) { - return; + setLidOpen(state) { + if (this.isLidOpen === state) { + return; + } + clearTimeout(this.lidTask_); + this.isLidOpen = state; + this.animateLid_(); } /** - * Current open/close state of the lid. - * @type {boolean} - */ - this.isLidOpen = false; - - /** - * The minimum openness of the lid. Used to indicate if the trashcan contains - * blocks. - * @type {number} + * Rotate the lid open or closed by one step. Then wait and recurse. * @private */ - this.minOpenness_ = 0; + animateLid_() { + const frames = ANIMATION_FRAMES; - /** - * The SVG group containing the trash can. - * @type {SVGElement} - * @private - */ - this.svgGroup_ = null; + const delta = 1 / (frames + 1); + this.lidOpen_ += this.isLidOpen ? delta : -delta; + this.lidOpen_ = Math.min(Math.max(this.lidOpen_, this.minOpenness_), 1); - /** - * The SVG image element of the trash can lid. - * @type {SVGElement} - * @private - */ - this.svgLid_ = null; + this.setLidAngle_(this.lidOpen_ * MAX_LID_ANGLE); - /** - * Task ID of opening/closing animation. - * @type {number} - * @private - */ - this.lidTask_ = 0; + // Linear interpolation between min and max. + const opacity = OPACITY_MIN + this.lidOpen_ * (OPACITY_MAX - OPACITY_MIN); + this.svgGroup_.style.opacity = opacity; - /** - * Current state of lid opening (0.0 = closed, 1.0 = open). - * @type {number} - * @private - */ - this.lidOpen_ = 0; - - /** - * Left coordinate of the trash can. - * @type {number} - * @private - */ - this.left_ = 0; - - /** - * Top coordinate of the trash can. - * @type {number} - * @private - */ - this.top_ = 0; - - /** - * Whether this trash can has been initialized. - * @type {boolean} - * @private - */ - this.initialized_ = false; - - // Create flyout options. - const flyoutWorkspaceOptions = new Options( - /** @type {!BlocklyOptions} */ - ({ - 'scrollbars': true, - 'parentWorkspace': this.workspace_, - 'rtl': this.workspace_.RTL, - 'oneBasedIndex': this.workspace_.options.oneBasedIndex, - 'renderer': this.workspace_.options.renderer, - 'rendererOverrides': this.workspace_.options.rendererOverrides, - 'move': { - 'scrollbars': true, - }, - })); - // Create vertical or horizontal flyout. - if (this.workspace_.horizontalLayout) { - flyoutWorkspaceOptions.toolboxPosition = - this.workspace_.toolboxPosition === toolbox.Position.TOP ? - toolbox.Position.BOTTOM : - toolbox.Position.TOP; - const HorizontalFlyout = registry.getClassFromOptions( - registry.Type.FLYOUTS_HORIZONTAL_TOOLBOX, this.workspace_.options, - true); - this.flyout = new HorizontalFlyout(flyoutWorkspaceOptions); - } else { - flyoutWorkspaceOptions.toolboxPosition = - this.workspace_.toolboxPosition === toolbox.Position.RIGHT ? - toolbox.Position.LEFT : - toolbox.Position.RIGHT; - const VerticalFlyout = registry.getClassFromOptions( - registry.Type.FLYOUTS_VERTICAL_TOOLBOX, this.workspace_.options, true); - this.flyout = new VerticalFlyout(flyoutWorkspaceOptions); + if (this.lidOpen_ > this.minOpenness_ && this.lidOpen_ < 1) { + this.lidTask_ = + setTimeout(this.animateLid_.bind(this), ANIMATION_LENGTH / frames); + } } - this.workspace_.addChangeListener(this.onDelete_.bind(this)); -}; -object.inherits(Trashcan, DeleteArea); + + /** + * Set the angle of the trashcan's lid. + * @param {number} lidAngle The angle at which to set the lid. + * @private + */ + setLidAngle_(lidAngle) { + const openAtRight = + this.workspace_.toolboxPosition === toolbox.Position.RIGHT || + (this.workspace_.horizontalLayout && this.workspace_.RTL); + this.svgLid_.setAttribute( + 'transform', + 'rotate(' + (openAtRight ? -lidAngle : lidAngle) + ',' + + (openAtRight ? 4 : WIDTH - 4) + ',' + (LID_HEIGHT - 2) + ')'); + } + + /** + * Sets the minimum openness of the trashcan lid. If the lid is currently + * closed, this will update lid's position. + * @param {number} newMin The new minimum openness of the lid. Should be + * between 0 and 1. + * @private + */ + setMinOpenness_(newMin) { + this.minOpenness_ = newMin; + if (!this.isLidOpen) { + this.setLidAngle_(newMin * MAX_LID_ANGLE); + } + } + + /** + * Flip the lid shut. + * Called externally after a drag. + */ + closeLid() { + this.setLidOpen(false); + } + + /** + * Inspect the contents of the trash. + */ + click() { + if (!this.hasContents_()) { + return; + } + this.openFlyout(); + } + + /** + * Fires a UI event for trashcan flyout open or close. + * @param {boolean} trashcanOpen Whether the flyout is opening. + * @private + */ + fireUiEvent_(trashcanOpen) { + const uiEvent = new (eventUtils.get(eventUtils.TRASHCAN_OPEN))( + trashcanOpen, this.workspace_.id); + eventUtils.fire(uiEvent); + } + + /** + * Prevents a workspace scroll and click event if the trashcan has blocks. + * @param {!Event} e A mouse down event. + * @private + */ + blockMouseDownWhenOpenable_(e) { + if (!this.contentsIsOpen() && this.hasContents_()) { + e.stopPropagation(); // Don't start a workspace scroll. + } + } + + /** + * Indicate that the trashcan can be clicked (by opening it) if it has blocks. + * @private + */ + mouseOver_() { + if (this.hasContents_()) { + this.setLidOpen(true); + } + } + + /** + * Close the lid of the trashcan if it was open (Vis. it was indicating it had + * blocks). + * @private + */ + mouseOut_() { + // No need to do a .hasBlocks check here because if it doesn't the trashcan + // won't be open in the first place, and setOpen won't run. + this.setLidOpen(false); + } + + /** + * Handle a BLOCK_DELETE event. Adds deleted blocks oldXml to the content + * array. + * @param {!Abstract} event Workspace event. + * @private + */ + onDelete_(event) { + if (this.workspace_.options.maxTrashcanContents <= 0) { + return; + } + if (event.type === eventUtils.BLOCK_DELETE && !event.wasShadow) { + const cleanedJson = this.cleanBlockJson_(event.oldJson); + if (this.contents_.indexOf(cleanedJson) !== -1) { + return; + } + this.contents_.unshift(cleanedJson); + while (this.contents_.length > + this.workspace_.options.maxTrashcanContents) { + this.contents_.pop(); + } + + this.setMinOpenness_(HAS_BLOCKS_LID_ANGLE); + } + } + + /** + * Converts JSON representing a block into text that can be stored in the + * content array. + * @param {!blocks.State} json A JSON representation of + * a block's state. + * @return {string} Text representing the JSON, cleaned of all unnecessary + * attributes. + * @private + */ + cleanBlockJson_(json) { + // Create a deep copy. + json = /** @type {!blocks.State} */ (JSON.parse(JSON.stringify(json))); + + /** + * Reshape JSON into a nicer format. + * @param {!blocks.State} json The JSON to clean. + */ + function cleanRec(json) { + if (!json) { + return; + } + + delete json['id']; + delete json['x']; + delete json['y']; + delete json['enabled']; + + if (json['icons'] && json['icons']['comment']) { + const comment = json['icons']['comment']; + delete comment['height']; + delete comment['width']; + delete comment['pinned']; + } + + const inputs = json['inputs']; + for (const name in inputs) { + const input = inputs[name]; + cleanRec(input['block']); + cleanRec(input['shadow']); + } + if (json['next']) { + const next = json['next']; + cleanRec(next['block']); + cleanRec(next['shadow']); + } + } + + cleanRec(json); + json['kind'] = 'BLOCK'; + return JSON.stringify(json); + } +} /** * Width of both the trash can and lid images. @@ -266,482 +750,4 @@ const OPACITY_MAX = 0.8; */ const MAX_LID_ANGLE = 45; -/** - * Create the trash can elements. - * @return {!SVGElement} The trash can's SVG group. - */ -Trashcan.prototype.createDom = function() { - /* Here's the markup that will be generated: - - - - - - - - - - - */ - this.svgGroup_ = dom.createSvgElement(Svg.G, {'class': 'blocklyTrash'}, null); - let clip; - const rnd = String(Math.random()).substring(2); - clip = dom.createSvgElement( - Svg.CLIPPATH, {'id': 'blocklyTrashBodyClipPath' + rnd}, this.svgGroup_); - dom.createSvgElement( - Svg.RECT, {'width': WIDTH, 'height': BODY_HEIGHT, 'y': LID_HEIGHT}, clip); - const body = dom.createSvgElement( - Svg.IMAGE, { - 'width': internalConstants.SPRITE.width, - 'x': -SPRITE_LEFT, - 'height': internalConstants.SPRITE.height, - 'y': -SPRITE_TOP, - 'clip-path': 'url(#blocklyTrashBodyClipPath' + rnd + ')', - }, - this.svgGroup_); - body.setAttributeNS( - dom.XLINK_NS, 'xlink:href', - this.workspace_.options.pathToMedia + internalConstants.SPRITE.url); - - clip = dom.createSvgElement( - Svg.CLIPPATH, {'id': 'blocklyTrashLidClipPath' + rnd}, this.svgGroup_); - dom.createSvgElement(Svg.RECT, {'width': WIDTH, 'height': LID_HEIGHT}, clip); - this.svgLid_ = dom.createSvgElement( - Svg.IMAGE, { - 'width': internalConstants.SPRITE.width, - 'x': -SPRITE_LEFT, - 'height': internalConstants.SPRITE.height, - 'y': -SPRITE_TOP, - 'clip-path': 'url(#blocklyTrashLidClipPath' + rnd + ')', - }, - this.svgGroup_); - this.svgLid_.setAttributeNS( - dom.XLINK_NS, 'xlink:href', - this.workspace_.options.pathToMedia + internalConstants.SPRITE.url); - - // bindEventWithChecks_ quashes events too aggressively. See: - // https://groups.google.com/forum/#!topic/blockly/QF4yB9Wx00s - // Using bindEventWithChecks_ for blocking mousedown causes issue in mobile. - // See #4303 - browserEvents.bind( - this.svgGroup_, 'mousedown', this, this.blockMouseDownWhenOpenable_); - browserEvents.bind(this.svgGroup_, 'mouseup', this, this.click); - // Bind to body instead of this.svgGroup_ so that we don't get lid jitters - browserEvents.bind(body, 'mouseover', this, this.mouseOver_); - browserEvents.bind(body, 'mouseout', this, this.mouseOut_); - this.animateLid_(); - return this.svgGroup_; -}; - -/** - * Initializes the trash can. - */ -Trashcan.prototype.init = function() { - if (this.workspace_.options.maxTrashcanContents > 0) { - dom.insertAfter( - this.flyout.createDom(Svg.SVG), this.workspace_.getParentSvg()); - this.flyout.init(this.workspace_); - } - this.workspace_.getComponentManager().addComponent({ - component: this, - weight: 1, - capabilities: [ - ComponentManager.Capability.AUTOHIDEABLE, - ComponentManager.Capability.DELETE_AREA, - ComponentManager.Capability.DRAG_TARGET, - ComponentManager.Capability.POSITIONABLE, - ], - }); - this.initialized_ = true; - this.setLidOpen(false); -}; - -/** - * Dispose of this trash can. - * Unlink from all DOM elements to prevent memory leaks. - * @suppress {checkTypes} - */ -Trashcan.prototype.dispose = function() { - this.workspace_.getComponentManager().removeComponent('trashcan'); - if (this.svgGroup_) { - dom.removeNode(this.svgGroup_); - this.svgGroup_ = null; - } - this.svgLid_ = null; - this.workspace_ = null; - clearTimeout(this.lidTask_); -}; - -/** - * Whether the trashcan has contents. - * @return {boolean} True if the trashcan has contents. - * @private - */ -Trashcan.prototype.hasContents_ = function() { - return !!this.contents_.length; -}; - -/** - * Returns true if the trashcan contents-flyout is currently open. - * @return {boolean} True if the trashcan contents-flyout is currently open. - */ -Trashcan.prototype.contentsIsOpen = function() { - return !!this.flyout && this.flyout.isVisible(); -}; - -/** - * Opens the trashcan flyout. - */ -Trashcan.prototype.openFlyout = function() { - if (this.contentsIsOpen()) { - return; - } - const contents = this.contents_.map(function(string) { - return JSON.parse(string); - }); - this.flyout.show(contents); - this.fireUiEvent_(true); -}; - -/** - * Closes the trashcan flyout. - */ -Trashcan.prototype.closeFlyout = function() { - if (!this.contentsIsOpen()) { - return; - } - this.flyout.hide(); - this.fireUiEvent_(false); - this.workspace_.recordDragTargets(); -}; - -/** - * Hides the component. Called in WorkspaceSvg.hideChaff. - * @param {boolean} onlyClosePopups Whether only popups should be closed. - * Flyouts should not be closed if this is true. - */ -Trashcan.prototype.autoHide = function(onlyClosePopups) { - // For now the trashcan flyout always autocloses because it overlays the - // trashcan UI (no trashcan to click to close it). - if (!onlyClosePopups && this.flyout) { - this.closeFlyout(); - } -}; - -/** - * Empties the trashcan's contents. If the contents-flyout is currently open - * it will be closed. - */ -Trashcan.prototype.emptyContents = function() { - if (!this.hasContents_()) { - return; - } - this.contents_.length = 0; - this.setMinOpenness_(0); - this.closeFlyout(); -}; - -/** - * Positions the trashcan. - * It is positioned in the opposite corner to the corner the - * categories/toolbox starts at. - * @param {!MetricsManager.UiMetrics} metrics The workspace metrics. - * @param {!Array} savedPositions List of rectangles that - * are already on the workspace. - */ -Trashcan.prototype.position = function(metrics, savedPositions) { - // Not yet initialized. - if (!this.initialized_) { - return; - } - - const cornerPosition = - uiPosition.getCornerOppositeToolbox(this.workspace_, metrics); - - const height = BODY_HEIGHT + LID_HEIGHT; - const startRect = uiPosition.getStartPositionRect( - cornerPosition, new Size(WIDTH, height), MARGIN_HORIZONTAL, - MARGIN_VERTICAL, metrics, this.workspace_); - - const verticalPosition = cornerPosition.vertical; - const bumpDirection = verticalPosition === uiPosition.verticalPosition.TOP ? - uiPosition.bumpDirection.DOWN : - uiPosition.bumpDirection.UP; - const positionRect = uiPosition.bumpPositionRect( - startRect, MARGIN_VERTICAL, bumpDirection, savedPositions); - - this.top_ = positionRect.top; - this.left_ = positionRect.left; - this.svgGroup_.setAttribute( - 'transform', 'translate(' + this.left_ + ',' + this.top_ + ')'); -}; - -/** - * Returns the bounding rectangle of the UI element in pixel units relative to - * the Blockly injection div. - * @return {?Rect} The UI elements's bounding box. Null if - * bounding box should be ignored by other UI elements. - */ -Trashcan.prototype.getBoundingRectangle = function() { - const bottom = this.top_ + BODY_HEIGHT + LID_HEIGHT; - const right = this.left_ + WIDTH; - return new Rect(this.top_, bottom, this.left_, right); -}; - -/** - * Returns the bounding rectangle of the drag target area in pixel units - * relative to viewport. - * @return {?Rect} The component's bounding box. Null if drag - * target area should be ignored. - */ -Trashcan.prototype.getClientRect = function() { - if (!this.svgGroup_) { - return null; - } - - const trashRect = this.svgGroup_.getBoundingClientRect(); - const top = trashRect.top + SPRITE_TOP - MARGIN_HOTSPOT; - const bottom = top + LID_HEIGHT + BODY_HEIGHT + 2 * MARGIN_HOTSPOT; - const left = trashRect.left + SPRITE_LEFT - MARGIN_HOTSPOT; - const right = left + WIDTH + 2 * MARGIN_HOTSPOT; - return new Rect(top, bottom, left, right); -}; - -/** - * Handles when a cursor with a block or bubble is dragged over this drag - * target. - * @param {!IDraggable} _dragElement The block or bubble currently being - * dragged. - * @override - */ -Trashcan.prototype.onDragOver = function(_dragElement) { - this.setLidOpen(this.wouldDelete_); -}; - -/** - * Handles when a cursor with a block or bubble exits this drag target. - * @param {!IDraggable} _dragElement The block or bubble currently being - * dragged. - * @override - */ -Trashcan.prototype.onDragExit = function(_dragElement) { - this.setLidOpen(false); -}; - -/** - * Handles when a block or bubble is dropped on this component. - * Should not handle delete here. - * @param {!IDraggable} _dragElement The block or bubble currently being - * dragged. - * @override - */ -Trashcan.prototype.onDrop = function(_dragElement) { - setTimeout(this.setLidOpen.bind(this, false), 100); -}; - -/** - * Flip the lid open or shut. - * @param {boolean} state True if open. - * @package - */ -Trashcan.prototype.setLidOpen = function(state) { - if (this.isLidOpen === state) { - return; - } - clearTimeout(this.lidTask_); - this.isLidOpen = state; - this.animateLid_(); -}; - -/** - * Rotate the lid open or closed by one step. Then wait and recurse. - * @private - */ -Trashcan.prototype.animateLid_ = function() { - const frames = ANIMATION_FRAMES; - - const delta = 1 / (frames + 1); - this.lidOpen_ += this.isLidOpen ? delta : -delta; - this.lidOpen_ = Math.min(Math.max(this.lidOpen_, this.minOpenness_), 1); - - this.setLidAngle_(this.lidOpen_ * MAX_LID_ANGLE); - - // Linear interpolation between min and max. - const opacity = OPACITY_MIN + this.lidOpen_ * (OPACITY_MAX - OPACITY_MIN); - this.svgGroup_.style.opacity = opacity; - - if (this.lidOpen_ > this.minOpenness_ && this.lidOpen_ < 1) { - this.lidTask_ = - setTimeout(this.animateLid_.bind(this), ANIMATION_LENGTH / frames); - } -}; - -/** - * Set the angle of the trashcan's lid. - * @param {number} lidAngle The angle at which to set the lid. - * @private - */ -Trashcan.prototype.setLidAngle_ = function(lidAngle) { - const openAtRight = - this.workspace_.toolboxPosition === toolbox.Position.RIGHT || - (this.workspace_.horizontalLayout && this.workspace_.RTL); - this.svgLid_.setAttribute( - 'transform', - 'rotate(' + (openAtRight ? -lidAngle : lidAngle) + ',' + - (openAtRight ? 4 : WIDTH - 4) + ',' + (LID_HEIGHT - 2) + ')'); -}; - -/** - * Sets the minimum openness of the trashcan lid. If the lid is currently - * closed, this will update lid's position. - * @param {number} newMin The new minimum openness of the lid. Should be between - * 0 and 1. - * @private - */ -Trashcan.prototype.setMinOpenness_ = function(newMin) { - this.minOpenness_ = newMin; - if (!this.isLidOpen) { - this.setLidAngle_(newMin * MAX_LID_ANGLE); - } -}; - -/** - * Flip the lid shut. - * Called externally after a drag. - */ -Trashcan.prototype.closeLid = function() { - this.setLidOpen(false); -}; - -/** - * Inspect the contents of the trash. - */ -Trashcan.prototype.click = function() { - if (!this.hasContents_()) { - return; - } - this.openFlyout(); -}; - -/** - * Fires a UI event for trashcan flyout open or close. - * @param {boolean} trashcanOpen Whether the flyout is opening. - * @private - */ -Trashcan.prototype.fireUiEvent_ = function(trashcanOpen) { - const uiEvent = new (eventUtils.get(eventUtils.TRASHCAN_OPEN))( - trashcanOpen, this.workspace_.id); - eventUtils.fire(uiEvent); -}; - -/** - * Prevents a workspace scroll and click event if the trashcan has blocks. - * @param {!Event} e A mouse down event. - * @private - */ -Trashcan.prototype.blockMouseDownWhenOpenable_ = function(e) { - if (!this.contentsIsOpen() && this.hasContents_()) { - e.stopPropagation(); // Don't start a workspace scroll. - } -}; - -/** - * Indicate that the trashcan can be clicked (by opening it) if it has blocks. - * @private - */ -Trashcan.prototype.mouseOver_ = function() { - if (this.hasContents_()) { - this.setLidOpen(true); - } -}; - -/** - * Close the lid of the trashcan if it was open (Vis. it was indicating it had - * blocks). - * @private - */ -Trashcan.prototype.mouseOut_ = function() { - // No need to do a .hasBlocks check here because if it doesn't the trashcan - // won't be open in the first place, and setOpen won't run. - this.setLidOpen(false); -}; - -/** - * Handle a BLOCK_DELETE event. Adds deleted blocks oldXml to the content array. - * @param {!Abstract} event Workspace event. - * @private - */ -Trashcan.prototype.onDelete_ = function(event) { - if (this.workspace_.options.maxTrashcanContents <= 0) { - return; - } - if (event.type === eventUtils.BLOCK_DELETE && !event.wasShadow) { - const cleanedJson = this.cleanBlockJson_(event.oldJson); - if (this.contents_.indexOf(cleanedJson) !== -1) { - return; - } - this.contents_.unshift(cleanedJson); - while (this.contents_.length > - this.workspace_.options.maxTrashcanContents) { - this.contents_.pop(); - } - - this.setMinOpenness_(HAS_BLOCKS_LID_ANGLE); - } -}; - -/** - * Converts JSON representing a block into text that can be stored in the - * content array. - * @param {!blocks.State} json A JSON representation of - * a block's state. - * @return {string} Text representing the JSON, cleaned of all unnecessary - * attributes. - * @private - */ -Trashcan.prototype.cleanBlockJson_ = function(json) { - // Create a deep copy. - json = /** @type {!blocks.State} */ (JSON.parse(JSON.stringify(json))); - - /** - * Reshape JSON into a nicer format. - * @param {!blocks.State} json The JSON to clean. - */ - function cleanRec(json) { - if (!json) { - return; - } - - delete json['id']; - delete json['x']; - delete json['y']; - delete json['enabled']; - - if (json['icons'] && json['icons']['comment']) { - const comment = json['icons']['comment']; - delete comment['height']; - delete comment['width']; - delete comment['pinned']; - } - - const inputs = json['inputs']; - for (const name in inputs) { - const input = inputs[name]; - cleanRec(input['block']); - cleanRec(input['shadow']); - } - if (json['next']) { - const next = json['next']; - cleanRec(next['block']); - cleanRec(next['shadow']); - } - } - - cleanRec(json); - json['kind'] = 'BLOCK'; - return JSON.stringify(json); -}; - exports.Trashcan = Trashcan; diff --git a/tests/deps.js b/tests/deps.js index 4d422f483..1946968c4 100644 --- a/tests/deps.js +++ b/tests/deps.js @@ -16,7 +16,7 @@ goog.addDependency('../../core/block_svg.js', ['Blockly.BlockSvg'], ['Blockly.AS goog.addDependency('../../core/blockly.js', ['Blockly'], ['Blockly.ASTNode', 'Blockly.BasicCursor', 'Blockly.Block', 'Blockly.BlockDragSurfaceSvg', 'Blockly.BlockDragger', 'Blockly.BlockSvg', 'Blockly.BlocklyOptions', 'Blockly.Bubble', 'Blockly.BubbleDragger', 'Blockly.CollapsibleToolboxCategory', 'Blockly.Comment', 'Blockly.ComponentManager', 'Blockly.Connection', 'Blockly.ConnectionChecker', 'Blockly.ConnectionDB', 'Blockly.ConnectionType', 'Blockly.ContextMenu', 'Blockly.ContextMenuItems', 'Blockly.ContextMenuRegistry', 'Blockly.Css', 'Blockly.Cursor', 'Blockly.DeleteArea', 'Blockly.DragTarget', 'Blockly.DropDownDiv', 'Blockly.Events', 'Blockly.Events.BlockCreate', 'Blockly.Events.FinishedLoading', 'Blockly.Events.Ui', 'Blockly.Events.UiBase', 'Blockly.Events.VarCreate', 'Blockly.Extensions', 'Blockly.Field', 'Blockly.FieldAngle', 'Blockly.FieldCheckbox', 'Blockly.FieldColour', 'Blockly.FieldDropdown', 'Blockly.FieldImage', 'Blockly.FieldLabel', 'Blockly.FieldLabelSerializable', 'Blockly.FieldMultilineInput', 'Blockly.FieldNumber', 'Blockly.FieldTextInput', 'Blockly.FieldVariable', 'Blockly.Flyout', 'Blockly.FlyoutButton', 'Blockly.FlyoutMetricsManager', 'Blockly.Generator', 'Blockly.Gesture', 'Blockly.Grid', 'Blockly.HorizontalFlyout', 'Blockly.IASTNodeLocation', 'Blockly.IASTNodeLocationSvg', 'Blockly.IASTNodeLocationWithBlock', 'Blockly.IAutoHideable', 'Blockly.IBlockDragger', 'Blockly.IBoundedElement', 'Blockly.IBubble', 'Blockly.ICollapsibleToolboxItem', 'Blockly.IComponent', 'Blockly.IConnectionChecker', 'Blockly.IContextMenu', 'Blockly.ICopyable', 'Blockly.IDeletable', 'Blockly.IDeleteArea', 'Blockly.IDragTarget', 'Blockly.IDraggable', 'Blockly.IFlyout', 'Blockly.IKeyboardAccessible', 'Blockly.IMetricsManager', 'Blockly.IMovable', 'Blockly.IPositionable', 'Blockly.IRegistrable', 'Blockly.IRegistrableField', 'Blockly.ISelectable', 'Blockly.ISelectableToolboxItem', 'Blockly.IStyleable', 'Blockly.IToolbox', 'Blockly.IToolboxItem', 'Blockly.Icon', 'Blockly.Input', 'Blockly.InsertionMarkerManager', 'Blockly.Marker', 'Blockly.MarkerManager', 'Blockly.Menu', 'Blockly.MenuItem', 'Blockly.MetricsManager', 'Blockly.Msg', 'Blockly.Mutator', 'Blockly.Names', 'Blockly.Options', 'Blockly.Procedures', 'Blockly.RenderedConnection', 'Blockly.Scrollbar', 'Blockly.ScrollbarPair', 'Blockly.ShortcutItems', 'Blockly.ShortcutRegistry', 'Blockly.TabNavigateCursor', 'Blockly.Theme', 'Blockly.ThemeManager', 'Blockly.Themes', 'Blockly.Toolbox', 'Blockly.ToolboxCategory', 'Blockly.ToolboxItem', 'Blockly.ToolboxSeparator', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.TouchGesture', 'Blockly.Trashcan', 'Blockly.VariableMap', 'Blockly.VariableModel', 'Blockly.Variables', 'Blockly.VariablesDynamic', 'Blockly.VerticalFlyout', 'Blockly.Warning', 'Blockly.WidgetDiv', 'Blockly.Workspace', 'Blockly.WorkspaceAudio', 'Blockly.WorkspaceComment', 'Blockly.WorkspaceCommentSvg', 'Blockly.WorkspaceDragSurfaceSvg', 'Blockly.WorkspaceDragger', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.ZoomControls', 'Blockly.blockAnimations', 'Blockly.blockRendering', 'Blockly.blocks', 'Blockly.browserEvents', 'Blockly.bumpObjects', 'Blockly.clipboard', 'Blockly.common', 'Blockly.constants', 'Blockly.dialog', 'Blockly.fieldRegistry', 'Blockly.geras', 'Blockly.inject', 'Blockly.inputTypes', 'Blockly.internalConstants', 'Blockly.minimalist', 'Blockly.registry', 'Blockly.serialization.ISerializer', 'Blockly.serialization.blocks', 'Blockly.serialization.exceptions', 'Blockly.serialization.priorities', 'Blockly.serialization.registry', 'Blockly.serialization.variables', 'Blockly.serialization.workspaces', 'Blockly.thrasos', 'Blockly.uiPosition', 'Blockly.utils', 'Blockly.utils.colour', 'Blockly.utils.deprecation', 'Blockly.utils.global', 'Blockly.utils.svgMath', 'Blockly.utils.toolbox', 'Blockly.zelos'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/blockly_options.js', ['Blockly.BlocklyOptions'], [], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/blocks.js', ['Blockly.blocks'], [], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/browser_events.js', ['Blockly.browserEvents'], ['Blockly.Touch', 'Blockly.internalConstants', 'Blockly.utils.global', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/browser_events.js', ['Blockly.browserEvents'], ['Blockly.Touch', 'Blockly.utils.global', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/bubble.js', ['Blockly.Bubble'], ['Blockly.IBubble', 'Blockly.Scrollbar', 'Blockly.Touch', 'Blockly.Workspace', 'Blockly.browserEvents', 'Blockly.utils.Coordinate', 'Blockly.utils.Size', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.math', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/bubble_dragger.js', ['Blockly.BubbleDragger'], ['Blockly.Bubble', 'Blockly.ComponentManager', 'Blockly.Events.CommentMove', 'Blockly.Events.utils', 'Blockly.constants', 'Blockly.utils.Coordinate', 'Blockly.utils.svgMath'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/bump_objects.js', ['Blockly.bumpObjects'], ['Blockly.Events.utils', 'Blockly.utils.math'], {'lang': 'es6', 'module': 'goog'}); @@ -80,11 +80,11 @@ goog.addDependency('../../core/field_number.js', ['Blockly.FieldNumber'], ['Bloc goog.addDependency('../../core/field_registry.js', ['Blockly.fieldRegistry'], ['Blockly.registry'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/field_textinput.js', ['Blockly.FieldTextInput'], ['Blockly.DropDownDiv', 'Blockly.Events.BlockChange', 'Blockly.Events.utils', 'Blockly.Field', 'Blockly.Msg', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.dialog', 'Blockly.fieldRegistry', 'Blockly.utils.Coordinate', 'Blockly.utils.KeyCodes', 'Blockly.utils.aria', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.parsing', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/field_variable.js', ['Blockly.FieldVariable'], ['Blockly.Events.BlockChange', 'Blockly.FieldDropdown', 'Blockly.Msg', 'Blockly.VariableModel', 'Blockly.Variables', 'Blockly.Xml', 'Blockly.fieldRegistry', 'Blockly.internalConstants', 'Blockly.utils.Size', 'Blockly.utils.object', 'Blockly.utils.parsing'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/flyout_base.js', ['Blockly.Flyout'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events.BlockCreate', 'Blockly.Events.VarCreate', 'Blockly.Events.utils', 'Blockly.FlyoutMetricsManager', 'Blockly.Gesture', 'Blockly.IFlyout', 'Blockly.ScrollbarPair', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.Variables', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.blockRendering', 'Blockly.browserEvents', 'Blockly.common', 'Blockly.serialization.blocks', 'Blockly.utils.Coordinate', 'Blockly.utils.Rect', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.idGenerator', 'Blockly.utils.object', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/flyout_base.js', ['Blockly.Flyout'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events.BlockCreate', 'Blockly.Events.VarCreate', 'Blockly.Events.utils', 'Blockly.FlyoutMetricsManager', 'Blockly.Gesture', 'Blockly.IFlyout', 'Blockly.ScrollbarPair', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.Variables', 'Blockly.WorkspaceSvg', 'Blockly.Xml', 'Blockly.blockRendering', 'Blockly.browserEvents', 'Blockly.common', 'Blockly.serialization.blocks', 'Blockly.utils.Coordinate', 'Blockly.utils.Rect', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.idGenerator', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/flyout_button.js', ['Blockly.FlyoutButton'], ['Blockly.Css', 'Blockly.browserEvents', 'Blockly.utils.Coordinate', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.parsing', 'Blockly.utils.style'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/flyout_horizontal.js', ['Blockly.HorizontalFlyout'], ['Blockly.DropDownDiv', 'Blockly.Flyout', 'Blockly.Scrollbar', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.registry', 'Blockly.utils.Rect', 'Blockly.utils.object', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/flyout_metrics_manager.js', ['Blockly.FlyoutMetricsManager'], ['Blockly.MetricsManager', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/flyout_vertical.js', ['Blockly.VerticalFlyout'], ['Blockly.Block', 'Blockly.DropDownDiv', 'Blockly.Flyout', 'Blockly.Scrollbar', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.constants', 'Blockly.registry', 'Blockly.utils.Rect', 'Blockly.utils.object', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/flyout_horizontal.js', ['Blockly.HorizontalFlyout'], ['Blockly.DropDownDiv', 'Blockly.Flyout', 'Blockly.Scrollbar', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.registry', 'Blockly.utils.Rect', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/flyout_metrics_manager.js', ['Blockly.FlyoutMetricsManager'], ['Blockly.MetricsManager'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/flyout_vertical.js', ['Blockly.VerticalFlyout'], ['Blockly.Block', 'Blockly.DropDownDiv', 'Blockly.Flyout', 'Blockly.Scrollbar', 'Blockly.WidgetDiv', 'Blockly.browserEvents', 'Blockly.constants', 'Blockly.registry', 'Blockly.utils.Rect', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/generator.js', ['Blockly.Generator'], ['Blockly.Names', 'Blockly.common', 'Blockly.utils.deprecation'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/gesture.js', ['Blockly.Gesture'], ['Blockly.BlockDragger', 'Blockly.BubbleDragger', 'Blockly.Events.Click', 'Blockly.Events.utils', 'Blockly.Tooltip', 'Blockly.Touch', 'Blockly.Workspace', 'Blockly.WorkspaceDragger', 'Blockly.blockAnimations', 'Blockly.browserEvents', 'Blockly.common', 'Blockly.internalConstants', 'Blockly.registry', 'Blockly.utils.Coordinate'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/grid.js', ['Blockly.Grid'], ['Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.userAgent'], {'lang': 'es6', 'module': 'goog'}); @@ -223,8 +223,8 @@ goog.addDependency('../../core/toolbox/toolbox.js', ['Blockly.Toolbox'], ['Block goog.addDependency('../../core/toolbox/toolbox_item.js', ['Blockly.ToolboxItem'], ['Blockly.IToolboxItem', 'Blockly.utils.idGenerator'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/tooltip.js', ['Blockly.Tooltip'], ['Blockly.browserEvents', 'Blockly.common', 'Blockly.utils.deprecation', 'Blockly.utils.string'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/touch.js', ['Blockly.Touch'], ['Blockly.utils.global', 'Blockly.utils.string'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/touch_gesture.js', ['Blockly.TouchGesture'], ['Blockly.Gesture', 'Blockly.Touch', 'Blockly.browserEvents', 'Blockly.utils.Coordinate', 'Blockly.utils.object'], {'lang': 'es6', 'module': 'goog'}); -goog.addDependency('../../core/trashcan.js', ['Blockly.Trashcan'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events.TrashcanOpen', 'Blockly.Events.utils', 'Blockly.IAutoHideable', 'Blockly.IPositionable', 'Blockly.Options', 'Blockly.browserEvents', 'Blockly.internalConstants', 'Blockly.registry', 'Blockly.uiPosition', 'Blockly.utils.Rect', 'Blockly.utils.Size', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.object', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/touch_gesture.js', ['Blockly.TouchGesture'], ['Blockly.Gesture', 'Blockly.Touch', 'Blockly.browserEvents', 'Blockly.utils.Coordinate'], {'lang': 'es6', 'module': 'goog'}); +goog.addDependency('../../core/trashcan.js', ['Blockly.Trashcan'], ['Blockly.ComponentManager', 'Blockly.DeleteArea', 'Blockly.Events.TrashcanOpen', 'Blockly.Events.utils', 'Blockly.IAutoHideable', 'Blockly.IPositionable', 'Blockly.Options', 'Blockly.browserEvents', 'Blockly.internalConstants', 'Blockly.registry', 'Blockly.uiPosition', 'Blockly.utils.Rect', 'Blockly.utils.Size', 'Blockly.utils.Svg', 'Blockly.utils.dom', 'Blockly.utils.toolbox'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/utils.js', ['Blockly.utils'], ['Blockly.Extensions', 'Blockly.browserEvents', 'Blockly.common', 'Blockly.utils.Coordinate', 'Blockly.utils.KeyCodes', 'Blockly.utils.Metrics', 'Blockly.utils.Rect', 'Blockly.utils.Size', 'Blockly.utils.Svg', 'Blockly.utils.aria', 'Blockly.utils.array', 'Blockly.utils.colour', 'Blockly.utils.deprecation', 'Blockly.utils.dom', 'Blockly.utils.global', 'Blockly.utils.idGenerator', 'Blockly.utils.math', 'Blockly.utils.object', 'Blockly.utils.parsing', 'Blockly.utils.string', 'Blockly.utils.style', 'Blockly.utils.svgMath', 'Blockly.utils.svgPaths', 'Blockly.utils.toolbox', 'Blockly.utils.userAgent', 'Blockly.utils.xml'], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/utils/aria.js', ['Blockly.utils.aria'], [], {'lang': 'es6', 'module': 'goog'}); goog.addDependency('../../core/utils/array.js', ['Blockly.utils.array'], [], {'lang': 'es6', 'module': 'goog'});