UNPKG

cloud-blocks

Version:

Cloud Blocks is a library for building scratch computing interfaces with Luxrobo MODI.

1,670 lines (1,539 loc) 73.8 kB
/** * @license * Visual Blocks Editor * * Copyright 2014 Google Inc. * https://developers.google.com/blockly/ * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @fileoverview Object representing a workspace rendered as SVG. * @author fraser@google.com (Neil Fraser) */ 'use strict'; goog.provide('Blockly.WorkspaceSvg'); // TODO(scr): Fix circular dependencies //goog.require('Blockly.BlockSvg'); goog.require('Blockly.Colours'); goog.require('Blockly.ConnectionDB'); goog.require('Blockly.constants'); goog.require('Blockly.DataCategory'); goog.require('Blockly.DropDownDiv'); goog.require('Blockly.Events.BlockCreate'); goog.require('Blockly.Gesture'); goog.require('Blockly.Grid'); goog.require('Blockly.Options'); goog.require('Blockly.scratchBlocksUtils'); goog.require('Blockly.ScrollbarPair'); goog.require('Blockly.Touch'); goog.require('Blockly.Trashcan'); //goog.require('Blockly.VerticalFlyout'); goog.require('Blockly.Workspace'); goog.require('Blockly.WorkspaceAudio'); goog.require('Blockly.WorkspaceComment'); goog.require('Blockly.WorkspaceCommentSvg'); goog.require('Blockly.WorkspaceCommentSvg.render'); goog.require('Blockly.WorkspaceDragSurfaceSvg'); goog.require('Blockly.Xml'); goog.require('Blockly.ZoomControls'); goog.require('goog.array'); goog.require('goog.dom'); goog.require('goog.math.Coordinate'); goog.require('goog.userAgent'); goog.require('goog.math.Rect'); /** * Class for a workspace. This is an onscreen area with optional trashcan, * scrollbars, bubbles, and dragging. * @param {!Blockly.Options} options Dictionary of options. * @param {Blockly.BlockDragSurfaceSvg=} opt_blockDragSurface Drag surface for * blocks. * @param {Blockly.WorkspaceDragSurfaceSvg=} opt_wsDragSurface Drag surface for * the workspace. * @extends {Blockly.Workspace} * @constructor */ Blockly.WorkspaceSvg = function ( options, opt_blockDragSurface, opt_wsDragSurface ) { Blockly.WorkspaceSvg.superClass_.constructor.call(this, options); this.getMetrics = options.getMetrics || Blockly.WorkspaceSvg.getTopLevelWorkspaceMetrics_; this.setMetrics = options.setMetrics || Blockly.WorkspaceSvg.setTopLevelWorkspaceMetrics_; Blockly.ConnectionDB.init(this); if (opt_blockDragSurface) { this.blockDragSurface_ = opt_blockDragSurface; } if (opt_wsDragSurface) { this.workspaceDragSurface_ = opt_wsDragSurface; } this.useWorkspaceDragSurface_ = this.workspaceDragSurface_ && Blockly.utils.is3dSupported(); /** * List of currently highlighted blocks. Block highlighting is often used to * visually mark blocks currently being executed. * @type !Array.<!Blockly.BlockSvg> * @private */ this.highlightedBlocks_ = []; /** * Object in charge of loading, storing, and playing audio for a workspace. * @type {Blockly.WorkspaceAudio} * @private */ this.audioManager_ = new Blockly.WorkspaceAudio(options.parentWorkspace); /** * This workspace's grid object or null. * @type {Blockly.Grid} * @private */ this.grid_ = this.options.gridPattern ? new Blockly.Grid(options.gridPattern, options.gridOptions) : null; this.registerToolboxCategoryCallback( Blockly.VARIABLE_CATEGORY_NAME, Blockly.DataCategory ); this.registerToolboxCategoryCallback( Blockly.PROCEDURE_CATEGORY_NAME, Blockly.Procedures.flyoutCategory ); }; goog.inherits(Blockly.WorkspaceSvg, Blockly.Workspace); /** * A wrapper function called when a resize event occurs. * You can pass the result to `unbindEvent_`. * @type {Array.<!Array>} */ Blockly.WorkspaceSvg.prototype.resizeHandlerWrapper_ = null; /** * The render status of an SVG workspace. * Returns `false` for headless workspaces and true for instances of * `Blockly.WorkspaceSvg`. * @type {boolean} */ Blockly.WorkspaceSvg.prototype.rendered = true; /** * Whether the workspace is visible. False if the workspace has been hidden * by calling `setVisible(false)`. * @type {boolean} * @private */ Blockly.WorkspaceSvg.prototype.isVisible_ = true; /** * Is this workspace the surface for a flyout? * @type {boolean} */ Blockly.WorkspaceSvg.prototype.isFlyout = false; /** * Is this workspace the surface for a mutator? * @type {boolean} * @package */ Blockly.WorkspaceSvg.prototype.isMutator = false; /** * Whether this workspace has resizes enabled. * Disable during batch operations for a performance improvement. * @type {boolean} * @private */ Blockly.WorkspaceSvg.prototype.resizesEnabled_ = true; /** * Whether this workspace has toolbox/flyout refreshes enabled. * Disable during batch operations for a performance improvement. * @type {boolean} * @private */ Blockly.WorkspaceSvg.prototype.toolboxRefreshEnabled_ = true; /** * Current horizontal scrolling offset in pixel units. * @type {number} */ Blockly.WorkspaceSvg.prototype.scrollX = 0; /** * Current vertical scrolling offset in pixel units. * @type {number} */ Blockly.WorkspaceSvg.prototype.scrollY = 0; /** * Horizontal scroll value when scrolling started in pixel units. * @type {number} */ Blockly.WorkspaceSvg.prototype.startScrollX = 0; /** * Vertical scroll value when scrolling started in pixel units. * @type {number} */ Blockly.WorkspaceSvg.prototype.startScrollY = 0; /** * Distance from mouse to object being dragged. * @type {goog.math.Coordinate} * @private */ Blockly.WorkspaceSvg.prototype.dragDeltaXY_ = null; /** * Current scale. * @type {number} */ Blockly.WorkspaceSvg.prototype.scale = 1; /** * The workspace's trashcan (if any). * @type {Blockly.Trashcan} */ Blockly.WorkspaceSvg.prototype.trashcan = null; /** * This workspace's scrollbars, if they exist. * @type {Blockly.ScrollbarPair} */ Blockly.WorkspaceSvg.prototype.scrollbar = null; /** * The current gesture in progress on this workspace, if any. * @type {Blockly.Gesture} * @private */ Blockly.WorkspaceSvg.prototype.currentGesture_ = null; /** * This workspace's surface for dragging blocks, if it exists. * @type {Blockly.BlockDragSurfaceSvg} * @private */ Blockly.WorkspaceSvg.prototype.blockDragSurface_ = null; /** * This workspace's drag surface, if it exists. * @type {Blockly.WorkspaceDragSurfaceSvg} * @private */ Blockly.WorkspaceSvg.prototype.workspaceDragSurface_ = null; /** * Whether to move workspace to the drag surface when it is dragged. * True if it should move, false if it should be translated directly. * @type {boolean} * @private */ Blockly.WorkspaceSvg.prototype.useWorkspaceDragSurface_ = false; /** * Whether the drag surface is actively in use. When true, calls to * translate will translate the drag surface instead of the translating the * workspace directly. * This is set to true in setupDragSurface and to false in resetDragSurface. * @type {boolean} * @private */ Blockly.WorkspaceSvg.prototype.isDragSurfaceActive_ = false; /** * The first parent div with 'injectionDiv' in the name, or null if not set. * Access this with getInjectionDiv. * @type {!Element} * @private */ Blockly.WorkspaceSvg.prototype.injectionDiv_ = null; /** * Last known position of the page scroll. * This is used to determine whether we have recalculated screen coordinate * stuff since the page scrolled. * @type {!goog.math.Coordinate} * @private */ Blockly.WorkspaceSvg.prototype.lastRecordedPageScroll_ = null; /** * Map from function names to callbacks, for deciding what to do when a button * is clicked. * @type {!Object.<string, function(!Blockly.FlyoutButton)>} * @private */ Blockly.WorkspaceSvg.prototype.flyoutButtonCallbacks_ = {}; /** * Map from function names to callbacks, for deciding what to do when a custom * toolbox category is opened. * @type {!Object.<string, function(!Blockly.Workspace):!Array.<!Element>>} * @private */ Blockly.WorkspaceSvg.prototype.toolboxCategoryCallbacks_ = {}; /** * Inverted screen CTM, for use in mouseToSvg. * @type {SVGMatrix} * @private */ Blockly.WorkspaceSvg.prototype.inverseScreenCTM_ = null; /** * Inverted screen CTM is dirty. * @type {Boolean} * @private */ Blockly.WorkspaceSvg.prototype.inverseScreenCTMDirty_ = true; /** * Getter for the inverted screen CTM. * @return {SVGMatrix} The matrix to use in mouseToSvg */ Blockly.WorkspaceSvg.prototype.getInverseScreenCTM = function () { // Defer getting the screen CTM until we actually need it, this should // avoid forced reflows from any calls to updateInverseScreenCTM. if (this.inverseScreenCTMDirty_) { var ctm = this.getParentSvg().getScreenCTM(); if (ctm) { this.inverseScreenCTM_ = ctm.inverse(); this.inverseScreenCTMDirty_ = false; } } return this.inverseScreenCTM_; }; /** * Getter for isVisible * @return {boolean} Whether the workspace is visible. False if the workspace has been hidden * by calling `setVisible(false)`. */ Blockly.WorkspaceSvg.prototype.isVisible = function () { return this.isVisible_; }; /** * Mark the inverse screen CTM as dirty. */ Blockly.WorkspaceSvg.prototype.updateInverseScreenCTM = function () { this.inverseScreenCTMDirty_ = true; }; /** * Return the absolute coordinates of the top-left corner of this element, * scales that after canvas SVG element, if it's a descendant. * The origin (0,0) is the top-left corner of the Blockly SVG. * @param {!Element} element Element to find the coordinates of. * @return {!goog.math.Coordinate} Object with .x and .y properties. * @private */ Blockly.WorkspaceSvg.prototype.getSvgXY = function (element) { var x = 0; var y = 0; var scale = 1; if ( goog.dom.contains(this.getCanvas(), element) || goog.dom.contains(this.getBubbleCanvas(), element) ) { // Before the SVG canvas, scale the coordinates. scale = this.scale; } do { // Loop through this block and every parent. var xy = Blockly.utils.getRelativeXY(element); if (element == this.getCanvas() || element == this.getBubbleCanvas()) { // After the SVG canvas, don't scale the coordinates. scale = 1; } x += xy.x * scale; y += xy.y * scale; element = element.parentNode; } while (element && element != this.getParentSvg()); return new goog.math.Coordinate(x, y); }; /** * Return the position of the workspace origin relative to the injection div * origin in pixels. * The workspace origin is where a block would render at position (0, 0). * It is not the upper left corner of the workspace SVG. * @return {!goog.math.Coordinate} Offset in pixels. * @package */ Blockly.WorkspaceSvg.prototype.getOriginOffsetInPixels = function () { return Blockly.utils.getInjectionDivXY_(this.svgBlockCanvas_); }; /** * Return the injection div that is a parent of this workspace. * Walks the DOM the first time it's called, then returns a cached value. * @return {!Element} The first parent div with 'injectionDiv' in the name. * @package */ Blockly.WorkspaceSvg.prototype.getInjectionDiv = function () { // NB: it would be better to pass this in at createDom, but is more likely to // break existing uses of Blockly. if (!this.injectionDiv_) { var element = this.svgGroup_; while (element) { var classes = element.getAttribute('class') || ''; if ((' ' + classes + ' ').indexOf(' injectionDiv ') != -1) { this.injectionDiv_ = element; break; } element = element.parentNode; } } return this.injectionDiv_; }; /** * Save resize handler data so we can delete it later in dispose. * @param {!Array.<!Array>} handler Data that can be passed to unbindEvent_. */ Blockly.WorkspaceSvg.prototype.setResizeHandlerWrapper = function (handler) { this.resizeHandlerWrapper_ = handler; }; /** * Create the workspace DOM elements. * @param {string=} opt_backgroundClass Either 'blocklyMainBackground' or * 'blocklyMutatorBackground'. * @return {!Element} The workspace's SVG group. */ Blockly.WorkspaceSvg.prototype.createDom = function (opt_backgroundClass) { /** * <g class="blocklyWorkspace"> * <rect class="blocklyMainBackground" height="100%" width="100%"></rect> * [Trashcan and/or flyout may go here] * <g class="blocklyBlockCanvas"></g> * <g class="blocklyBubbleCanvas"></g> * </g> * @type {SVGElement} */ this.svgGroup_ = Blockly.utils.createSvgElement( 'g', { class: 'blocklyWorkspace' }, null ); // Note that a <g> alone does not receive mouse events--it must have a // valid target inside it. If no background class is specified, as in the // flyout, the workspace will not receive mouse events. if (opt_backgroundClass) { /** @type {SVGElement} */ this.svgBackground_ = Blockly.utils.createSvgElement( 'rect', { height: '100%', width: '100%', class: opt_backgroundClass }, this.svgGroup_ ); if (opt_backgroundClass == 'blocklyMainBackground' && this.grid_) { this.svgBackground_.style.fill = 'url(#' + this.grid_.getPatternId() + ')'; } } /** @type {SVGElement} */ this.svgBlockCanvas_ = Blockly.utils.createSvgElement( 'g', { class: 'blocklyBlockCanvas' }, this.svgGroup_, this ); /** @type {SVGElement} */ this.svgBubbleCanvas_ = Blockly.utils.createSvgElement( 'g', { class: 'blocklyBubbleCanvas' }, this.svgGroup_, this ); var bottom = Blockly.Scrollbar.scrollbarThickness; if (this.options.hasTrashcan) { bottom = this.addTrashcan_(bottom); } if (this.options.zoomOptions && this.options.zoomOptions.controls) { this.addZoomControls_(bottom); } if (!this.isFlyout) { Blockly.bindEventWithChecks_( this.svgGroup_, 'mousedown', this, this.onMouseDown_ ); if (this.options.zoomOptions && this.options.zoomOptions.wheel) { // Mouse-wheel. Blockly.bindEventWithChecks_( this.svgGroup_, 'wheel', this, this.onMouseWheel_ ); } } // Determine if there needs to be a category tree, or a simple list of // blocks. This cannot be changed later, since the UI is very different. if (this.options.hasCategories) { /** * @type {Blockly.Toolbox} * @private */ this.toolbox_ = new Blockly.Toolbox(this); } if (this.grid_) { this.grid_.update(this.scale); } this.recordCachedAreas(); return this.svgGroup_; }; /** * Dispose of this workspace. * Unlink from all DOM elements to prevent memory leaks. */ Blockly.WorkspaceSvg.prototype.dispose = function () { // Stop rerendering. this.rendered = false; if (this.currentGesture_) { this.currentGesture_.cancel(); } Blockly.WorkspaceSvg.superClass_.dispose.call(this); if (this.svgGroup_) { goog.dom.removeNode(this.svgGroup_); this.svgGroup_ = null; } this.svgBlockCanvas_ = null; this.svgBubbleCanvas_ = null; if (this.toolbox_) { this.toolbox_.dispose(); this.toolbox_ = null; } if (this.flyout_) { this.flyout_.dispose(); this.flyout_ = null; } if (this.trashcan) { this.trashcan.dispose(); this.trashcan = null; } if (this.scrollbar) { this.scrollbar.dispose(); this.scrollbar = null; } if (this.zoomControls_) { this.zoomControls_.dispose(); this.zoomControls_ = null; } if (this.audioManager_) { this.audioManager_.dispose(); this.audioManager_ = null; } if (this.grid_) { this.grid_.dispose(); this.grid_ = null; } if (this.toolboxCategoryCallbacks_) { this.toolboxCategoryCallbacks_ = null; } if (this.flyoutButtonCallbacks_) { this.flyoutButtonCallbacks_ = null; } if (!this.options.parentWorkspace) { // Top-most workspace. Dispose of the div that the // SVG is injected into (i.e. injectionDiv). goog.dom.removeNode(this.getParentSvg().parentNode); } if (this.resizeHandlerWrapper_) { Blockly.unbindEvent_(this.resizeHandlerWrapper_); this.resizeHandlerWrapper_ = null; } }; /** * Obtain a newly created block. * @param {?string} prototypeName Name of the language object containing * type-specific functions for this block. * @param {string=} opt_id Optional ID. Use this ID if provided, otherwise * create a new ID. * @return {!Blockly.BlockSvg} The created block. */ Blockly.WorkspaceSvg.prototype.newBlock = function (prototypeName, opt_id) { return new Blockly.BlockSvg(this, prototypeName, opt_id); }; /** * Add a trashcan. * @param {number} bottom Distance from workspace bottom to bottom of trashcan. * @return {number} Distance from workspace bottom to the top of trashcan. * @private */ Blockly.WorkspaceSvg.prototype.addTrashcan_ = function (bottom) { /** @type {Blockly.Trashcan} */ this.trashcan = new Blockly.Trashcan(this); var svgTrashcan = this.trashcan.createDom(); this.svgGroup_.insertBefore(svgTrashcan, this.svgBlockCanvas_); return this.trashcan.init(bottom); }; /** * Add zoom controls. * @param {number} bottom Distance from workspace bottom to bottom of controls. * @return {number} Distance from workspace bottom to the top of controls. * @private */ Blockly.WorkspaceSvg.prototype.addZoomControls_ = function (bottom) { /** @type {Blockly.ZoomControls} */ this.zoomControls_ = new Blockly.ZoomControls(this); var svgZoomControls = this.zoomControls_.createDom(); this.svgGroup_.appendChild(svgZoomControls); return this.zoomControls_.init(bottom); }; /** * Add a flyout element in an element with the given tag name. * @param {string} tagName What type of tag the flyout belongs in. * @return {!Element} The element containing the flyout DOM. * @private */ Blockly.WorkspaceSvg.prototype.addFlyout_ = function (tagName) { var workspaceOptions = { disabledPatternId: this.options.disabledPatternId, parentWorkspace: this, RTL: this.RTL, oneBasedIndex: this.options.oneBasedIndex, horizontalLayout: this.horizontalLayout, toolboxPosition: this.options.toolboxPosition, stackGlowFilterId: this.options.stackGlowFilterId }; if (this.horizontalLayout) { this.flyout_ = new Blockly.HorizontalFlyout(workspaceOptions); } else { this.flyout_ = new Blockly.VerticalFlyout(workspaceOptions); } this.flyout_.autoClose = false; // Return the element so that callers can place it in their desired // spot in the DOM. For example, mutator flyouts do not go in the same place // as main workspace flyouts. return this.flyout_.createDom(tagName); }; /** * Getter for the flyout associated with this workspace. This flyout may be * owned by either the toolbox or the workspace, depending on toolbox * configuration. It will be null if there is no flyout. * @return {Blockly.Flyout} The flyout on this workspace. * @package */ Blockly.WorkspaceSvg.prototype.getFlyout = function () { if (this.flyout_) { return this.flyout_; } if (this.toolbox_) { return this.toolbox_.flyout_; } return null; }; /** * Getter for the toolbox associated with this workspace, if one exists. * @return {Blockly.Toolbox} The toolbox on this workspace. * @package */ Blockly.WorkspaceSvg.prototype.getToolbox = function () { return this.toolbox_; }; /** * Update items that use screen coordinate calculations * because something has changed (e.g. scroll position, window size). * @private */ Blockly.WorkspaceSvg.prototype.updateScreenCalculations_ = function () { this.updateInverseScreenCTM(); this.recordCachedAreas(); }; /** * If enabled, resize the parts of the workspace that change when the workspace * contents (e.g. block positions) change. This will also scroll the * workspace contents if needed. * @package */ Blockly.WorkspaceSvg.prototype.resizeContents = function () { if (!this.resizesEnabled_ || !this.rendered) { return; } if (this.scrollbar) { // TODO(picklesrus): Once rachel-fenichel's scrollbar refactoring // is complete, call the method that only resizes scrollbar // based on contents. this.scrollbar.resize(); } this.updateInverseScreenCTM(); }; /** * Resize and reposition all of the workspace chrome (toolbox, * trash, scrollbars etc.) * This should be called when something changes that * requires recalculating dimensions and positions of the * trash, zoom, toolbox, etc. (e.g. window resize). */ Blockly.WorkspaceSvg.prototype.resize = function () { if (this.toolbox_) { this.toolbox_.position(); } if (this.flyout_) { this.flyout_.position(); } if (this.trashcan) { this.trashcan.position(); } if (this.zoomControls_) { this.zoomControls_.position(); } if (this.scrollbar) { this.scrollbar.resize(); } this.updateScreenCalculations_(); }; /** * Resizes and repositions workspace chrome if the page has a new * scroll position. * @package */ Blockly.WorkspaceSvg.prototype.updateScreenCalculationsIfScrolled = function () { /* eslint-disable indent */ var currScroll = goog.dom.getDocumentScroll(); if (!goog.math.Coordinate.equals(this.lastRecordedPageScroll_, currScroll)) { this.lastRecordedPageScroll_ = currScroll; this.updateScreenCalculations_(); } }; /* eslint-enable indent */ /** * Get the SVG element that forms the drawing surface. * @return {!Element} SVG element. */ Blockly.WorkspaceSvg.prototype.getCanvas = function () { return this.svgBlockCanvas_; }; /** * Get the SVG element that forms the bubble surface. * @return {!SVGGElement} SVG element. */ Blockly.WorkspaceSvg.prototype.getBubbleCanvas = function () { return this.svgBubbleCanvas_; }; /** * Get the SVG element that contains this workspace. * @return {!Element} SVG element. */ Blockly.WorkspaceSvg.prototype.getParentSvg = function () { if (this.cachedParentSvg_) { return this.cachedParentSvg_; } var element = this.svgGroup_; while (element) { if (element.tagName == 'svg') { this.cachedParentSvg_ = element; return element; } element = element.parentNode; } return null; }; /** * Translate this workspace to new coordinates. * @param {number} x Horizontal translation. * @param {number} y Vertical translation. */ Blockly.WorkspaceSvg.prototype.translate = function (x, y) { if (this.useWorkspaceDragSurface_ && this.isDragSurfaceActive_) { this.workspaceDragSurface_.translateSurface(x, y); } else { var translation = 'translate(' + x + ',' + y + ') ' + 'scale(' + this.scale + ')'; this.svgBlockCanvas_.setAttribute('transform', translation); this.svgBubbleCanvas_.setAttribute('transform', translation); } // Now update the block drag surface if we're using one. if (this.blockDragSurface_) { this.blockDragSurface_.translateAndScaleGroup(x, y, this.scale); } }; /** * Called at the end of a workspace drag to take the contents * out of the drag surface and put them back into the workspace SVG. * Does nothing if the workspace drag surface is not enabled. * @package */ Blockly.WorkspaceSvg.prototype.resetDragSurface = function () { // Don't do anything if we aren't using a drag surface. if (!this.useWorkspaceDragSurface_) { return; } this.isDragSurfaceActive_ = false; var trans = this.workspaceDragSurface_.getSurfaceTranslation(); this.workspaceDragSurface_.clearAndHide(this.svgGroup_); var translation = 'translate(' + trans.x + ',' + trans.y + ') ' + 'scale(' + this.scale + ')'; this.svgBlockCanvas_.setAttribute('transform', translation); this.svgBubbleCanvas_.setAttribute('transform', translation); }; /** * Called at the beginning of a workspace drag to move contents of * the workspace to the drag surface. * Does nothing if the drag surface is not enabled. * @package */ Blockly.WorkspaceSvg.prototype.setupDragSurface = function () { // Don't do anything if we aren't using a drag surface. if (!this.useWorkspaceDragSurface_) { return; } // This can happen if the user starts a drag, mouses up outside of the // document where the mouseup listener is registered (e.g. outside of an // iframe) and then moves the mouse back in the workspace. On mobile and ff, // we get the mouseup outside the frame. On chrome and safari desktop we do // not. if (this.isDragSurfaceActive_) { return; } this.isDragSurfaceActive_ = true; // Figure out where we want to put the canvas back. The order // in the is important because things are layered. var previousElement = this.svgBlockCanvas_.previousSibling; var width = parseInt(this.getParentSvg().getAttribute('width'), 10); var height = parseInt(this.getParentSvg().getAttribute('height'), 10); var coord = Blockly.utils.getRelativeXY(this.svgBlockCanvas_); this.workspaceDragSurface_.setContentsAndShow( this.svgBlockCanvas_, this.svgBubbleCanvas_, previousElement, width, height, this.scale ); this.workspaceDragSurface_.translateSurface(coord.x, coord.y); }; /** * @return {?Blockly.BlockDragSurfaceSvg} This workspace's block drag surface, * if one is in use. * @package */ Blockly.WorkspaceSvg.prototype.getBlockDragSurface = function () { return this.blockDragSurface_; }; /** * Returns the horizontal offset of the workspace. * Intended for LTR/RTL compatibility in XML. * @return {number} Width. */ Blockly.WorkspaceSvg.prototype.getWidth = function () { var metrics = this.getMetrics(); return metrics ? metrics.viewWidth / this.scale : 0; }; /** * Toggles the visibility of the workspace. * Currently only intended for main workspace. * @param {boolean} isVisible True if workspace should be visible. */ Blockly.WorkspaceSvg.prototype.setVisible = function (isVisible) { // Tell the scrollbar whether its container is visible so it can // tell when to hide itself. if (this.scrollbar) { this.scrollbar.setContainerVisible(isVisible); } // Tell the flyout whether its container is visible so it can // tell when to hide itself. if (this.getFlyout()) { this.getFlyout().setContainerVisible(isVisible); } this.getParentSvg().style.display = isVisible ? 'block' : 'none'; if (this.toolbox_) { // Currently does not support toolboxes in mutators. this.toolbox_.HtmlDiv.style.display = isVisible ? 'block' : 'none'; } if (isVisible) { this.render(); // The window may have changed size while the workspace was hidden. // Resize recalculates scrollbar position, delete areas, etc. this.resize(); } else { Blockly.hideChaff(true); Blockly.DropDownDiv.hideWithoutAnimation(); } this.isVisible_ = isVisible; }; /** * Render all blocks in workspace. */ Blockly.WorkspaceSvg.prototype.render = function () { // Generate list of all blocks. var blocks = this.getAllBlocks(); // Render each block. for (var i = blocks.length - 1; i >= 0; i--) { blocks[i].render(false); } }; /** * Was used back when block highlighting (for execution) and block selection * (for editing) were the same thing. * Any calls of this function can be deleted. * @deprecated October 2016 */ Blockly.WorkspaceSvg.prototype.traceOn = function () { console.warn('Deprecated call to traceOn, delete this.'); }; /** * Highlight or unhighlight a block in the workspace. Block highlighting is * often used to visually mark blocks currently being executed. * @param {?string} id ID of block to highlight/unhighlight, * or null for no block (used to unhighlight all blocks). * @param {boolean=} opt_state If undefined, highlight specified block and * automatically unhighlight all others. If true or false, manually * highlight/unhighlight the specified block. */ Blockly.WorkspaceSvg.prototype.highlightBlock = function (id, opt_state) { if (opt_state === undefined) { // Unhighlight all blocks. for (var i = 0, block; (block = this.highlightedBlocks_[i]); i++) { block.setHighlighted(false); } this.highlightedBlocks_.length = 0; } // Highlight/unhighlight the specified block. var block = id ? this.getBlockById(id) : null; if (block) { var state = opt_state === undefined || opt_state; // Using Set here would be great, but at the cost of IE10 support. if (!state) { goog.array.remove(this.highlightedBlocks_, block); } else if (this.highlightedBlocks_.indexOf(block) == -1) { this.highlightedBlocks_.push(block); } block.setHighlighted(state); } }; /** * Glow/unglow a block in the workspace. * @param {?string} id ID of block to find. * @param {boolean} isGlowingBlock Whether to glow the block. */ Blockly.WorkspaceSvg.prototype.glowBlock = function (id, isGlowingBlock) { var block = null; if (id) { block = this.getBlockById(id); if (!block) { throw 'Tried to glow block that does not exist.'; } } block.setGlowBlock(isGlowingBlock); }; /** * Glow/unglow a stack in the workspace. * @param {?string} id ID of block which starts the stack. * @param {boolean} isGlowingStack Whether to glow the stack. */ Blockly.WorkspaceSvg.prototype.glowStack = function (id, isGlowingStack) { var block = null; if (id) { block = this.getBlockById(id); if (!block) { throw 'Tried to glow stack on block that does not exist.'; } } block.setGlowStack(isGlowingStack); }; /** * Visually report a value associated with a block. * In Scratch, appears as a pop-up next to the block when a reporter block is clicked. * @param {?string} id ID of block to report associated value. * @param {?string} value String value to visually report. */ Blockly.WorkspaceSvg.prototype.reportValue = function (id, value) { var block = this.getBlockById(id); if (!block) { throw 'Tried to report value on block that does not exist.'; } Blockly.DropDownDiv.hideWithoutAnimation(); Blockly.DropDownDiv.clearContent(); var contentDiv = Blockly.DropDownDiv.getContentDiv(); var valueReportBox = goog.dom.createElement('div'); valueReportBox.setAttribute('class', 'valueReportBox'); valueReportBox.innerHTML = Blockly.scratchBlocksUtils.encodeEntities(value); contentDiv.appendChild(valueReportBox); Blockly.DropDownDiv.setColour( Blockly.Colours.valueReportBackground, Blockly.Colours.valueReportBorder ); Blockly.DropDownDiv.showPositionedByBlock(this, block); }; /** * Paste the provided block onto the workspace. * @param {!Element} xmlBlock XML block element. */ Blockly.WorkspaceSvg.prototype.paste = function (xmlBlock) { if (!this.rendered) { return; } if (this.currentGesture_) { this.currentGesture_.cancel(); // Dragging while pasting? No. } if (xmlBlock.tagName.toLowerCase() == 'comment') { this.pasteWorkspaceComment_(xmlBlock); } else { this.pasteBlock_(xmlBlock); } }; /** * Paste the provided block onto the workspace. * @param {!Element} xmlBlock XML block element. */ Blockly.WorkspaceSvg.prototype.pasteBlock_ = function (xmlBlock) { Blockly.Events.disable(); try { var block = Blockly.Xml.domToBlock(xmlBlock, this); // Scratch-specific: Give shadow dom new IDs to prevent duplicating on paste Blockly.scratchBlocksUtils.changeObscuredShadowIds(block); // Move the duplicate to original position. var blockX = parseInt(xmlBlock.getAttribute('x'), 10); var blockY = parseInt(xmlBlock.getAttribute('y'), 10); if (!isNaN(blockX) && !isNaN(blockY)) { if (this.RTL) { blockX = -blockX; } // Offset block until not clobbering another block and not in connection // distance with neighbouring blocks. do { var collide = false; var allBlocks = this.getAllBlocks(); for (var i = 0, otherBlock; (otherBlock = allBlocks[i]); i++) { var otherXY = otherBlock.getRelativeToSurfaceXY(); if ( Math.abs(blockX - otherXY.x) <= 1 && Math.abs(blockY - otherXY.y) <= 1 ) { collide = true; break; } } if (!collide) { // Check for blocks in snap range to any of its connections. var connections = block.getConnections_(false); for (var i = 0, connection; (connection = connections[i]); i++) { var neighbour = connection.closest( Blockly.SNAP_RADIUS, new goog.math.Coordinate(blockX, blockY) ); if (neighbour.connection) { collide = true; break; } } } if (collide) { if (this.RTL) { blockX -= Blockly.SNAP_RADIUS; } else { blockX += Blockly.SNAP_RADIUS; } blockY += Blockly.SNAP_RADIUS * 2; } } while (collide); block.moveBy(blockX, blockY); } } finally { Blockly.Events.enable(); } if (Blockly.Events.isEnabled() && !block.isShadow()) { Blockly.Events.fire(new Blockly.Events.BlockCreate(block)); } block.select(); }; /** * Paste the provided comment onto the workspace. * @param {!Element} xmlComment XML workspace comment element. * @private */ Blockly.WorkspaceSvg.prototype.pasteWorkspaceComment_ = function (xmlComment) { Blockly.Events.disable(); try { var comment = Blockly.WorkspaceCommentSvg.fromXml(xmlComment, this); // Move the duplicate to original position. var commentX = parseInt(xmlComment.getAttribute('x'), 10); var commentY = parseInt(xmlComment.getAttribute('y'), 10); if (!isNaN(commentX) && !isNaN(commentY)) { if (this.RTL) { commentX = -commentX; } // Offset workspace comment. // TODO: (github.com/google/blockly/issues/1719) properly offset comment // such that it's not interfereing with any blocks commentX += 50; commentY += 50; comment.moveBy(commentX, commentY); } } finally { Blockly.Events.enable(); } if (Blockly.Events.isEnabled()) { Blockly.WorkspaceComment.fireCreateEvent(comment); } comment.select(); }; /** * Refresh the toolbox unless there's a drag in progress. * @private */ Blockly.WorkspaceSvg.prototype.refreshToolboxSelection_ = function () { // Updating the toolbox can be expensive. Don't do it when when it is // disabled. if (this.toolbox_) { if ( this.toolbox_.flyout_ && !this.currentGesture_ && this.toolboxRefreshEnabled_ ) { this.toolbox_.refreshSelection(); } } else { var thisTarget = this.targetWorkspace; if ( thisTarget && thisTarget.toolbox_ && thisTarget.toolbox_.flyout_ && !thisTarget.currentGesture_ && thisTarget.toolboxRefreshEnabled_ ) { thisTarget.toolbox_.refreshSelection(); } } }; /** * Rename a variable by updating its name in the variable map. Update the * flyout to show the renamed variable immediately. * @param {string} id ID of the variable to rename. * @param {string} newName New variable name. * @package */ Blockly.WorkspaceSvg.prototype.renameVariableById = function (id, newName) { Blockly.WorkspaceSvg.superClass_.renameVariableById.call(this, id, newName); this.refreshToolboxSelection_(); }; /** * Delete a variable by the passed in ID. Update the flyout to show * immediately that the variable is deleted. * @param {string} id ID of variable to delete. * @package */ Blockly.WorkspaceSvg.prototype.deleteVariableById = function (id) { Blockly.WorkspaceSvg.superClass_.deleteVariableById.call(this, id); this.refreshToolboxSelection_(); }; /** * Create a new variable with the given name. Update the flyout to show the new * variable immediately. * @param {string} name The new variable's name. * @param {string=} opt_type The type of the variable like 'int' or 'string'. * Does not need to be unique. Field_variable can filter variables based on * their type. This will default to '' which is a specific type. * @param {string=} opt_id The unique ID of the variable. This will default to * a UUID. * @param {boolean=} opt_isLocal Whether the variable is locally scoped. * @param {boolean=} opt_isCloud Whether the variable is a cloud variable. * @return {?Blockly.VariableModel} The newly created variable. * @package */ Blockly.WorkspaceSvg.prototype.createVariable = function ( name, opt_type, opt_id, opt_isLocal, opt_isCloud ) { var variableInMap = this.getVariable(name, opt_type) != null; var newVar = Blockly.WorkspaceSvg.superClass_.createVariable.call( this, name, opt_type, opt_id, opt_isLocal, opt_isCloud ); // For performance reasons, only refresh the the toolbox for new variables. // Variables that already exist should already be there. if (!variableInMap && opt_type != Blockly.BROADCAST_MESSAGE_VARIABLE_TYPE) { this.refreshToolboxSelection_(); } return newVar; }; /** * Update cached areas for this workspace. */ Blockly.WorkspaceSvg.prototype.recordCachedAreas = function () { this.recordBlocksArea_(); this.recordDeleteAreas_(); }; /** * Make a list of all the delete areas for this workspace. * @private */ Blockly.WorkspaceSvg.prototype.recordDeleteAreas_ = function () { if (this.trashcan) { this.deleteAreaTrash_ = this.trashcan.getClientRect(); } else { this.deleteAreaTrash_ = null; } if (this.flyout_) { this.deleteAreaToolbox_ = this.flyout_.getClientRect(); } else if (this.toolbox_) { this.deleteAreaToolbox_ = this.toolbox_.getClientRect(); } else { this.deleteAreaToolbox_ = null; } }; /** * Record where all of blocks GUI is on the screen * @private */ Blockly.WorkspaceSvg.prototype.recordBlocksArea_ = function () { var parentSvg = this.getParentSvg(); if (parentSvg) { var bounds = parentSvg.getBoundingClientRect(); this.blocksArea_ = new goog.math.Rect( bounds.left, bounds.top, bounds.width, bounds.height ); } else { this.blocksArea_ = null; } }; /** * Is the mouse event over a delete area (toolbox or non-closing flyout)? * @param {!Event} e Mouse move event. * @return {?number} Null if not over a delete area, or an enum representing * which delete area the event is over. */ Blockly.WorkspaceSvg.prototype.isDeleteArea = function (e) { var xy = new goog.math.Coordinate(e.clientX, e.clientY); if (this.deleteAreaTrash_ && this.deleteAreaTrash_.contains(xy)) { return Blockly.DELETE_AREA_TRASH; } if (this.deleteAreaToolbox_ && this.deleteAreaToolbox_.contains(xy)) { return Blockly.DELETE_AREA_TOOLBOX; } return Blockly.DELETE_AREA_NONE; }; /** * Is the mouse event inside the blocks UI? * @param {!Event} e Mouse move event. * @return {boolean} True if event is within the bounds of the blocks UI or delete area */ Blockly.WorkspaceSvg.prototype.isInsideBlocksArea = function (e) { var xy = new goog.math.Coordinate(e.clientX, e.clientY); if ( this.isDeleteArea(e) || (this.blocksArea_ && this.blocksArea_.contains(xy)) ) { return true; } return false; }; /** * Handle a mouse-down on SVG drawing surface. * @param {!Event} e Mouse down event. * @private */ Blockly.WorkspaceSvg.prototype.onMouseDown_ = function (e) { var gesture = this.getGesture(e); if (gesture) { gesture.handleWsStart(e, this); } }; /** * Start tracking a drag of an object on this workspace. * @param {!Event} e Mouse down event. * @param {!goog.math.Coordinate} xy Starting location of object. */ Blockly.WorkspaceSvg.prototype.startDrag = function (e, xy) { // Record the starting offset between the bubble's location and the mouse. var point = Blockly.utils.mouseToSvg( e, this.getParentSvg(), this.getInverseScreenCTM() ); // Fix scale of mouse event. point.x /= this.scale; point.y /= this.scale; this.dragDeltaXY_ = goog.math.Coordinate.difference(xy, point); }; /** * Track a drag of an object on this workspace. * @param {!Event} e Mouse move event. * @return {!goog.math.Coordinate} New location of object. */ Blockly.WorkspaceSvg.prototype.moveDrag = function (e) { var point = Blockly.utils.mouseToSvg( e, this.getParentSvg(), this.getInverseScreenCTM() ); // Fix scale of mouse event. point.x /= this.scale; point.y /= this.scale; return goog.math.Coordinate.sum(this.dragDeltaXY_, point); }; /** * Is the user currently dragging a block or scrolling the flyout/workspace? * @return {boolean} True if currently dragging or scrolling. */ Blockly.WorkspaceSvg.prototype.isDragging = function () { return this.currentGesture_ && this.currentGesture_.isDragging(); }; /** * Is this workspace draggable and scrollable? * @return {boolean} True if this workspace may be dragged. */ Blockly.WorkspaceSvg.prototype.isDraggable = function () { return !!this.scrollbar; }; /** * Handle a mouse-wheel on SVG drawing surface. * @param {!Event} e Mouse wheel event. * @private */ Blockly.WorkspaceSvg.prototype.onMouseWheel_ = function (e) { // TODO: Remove gesture cancellation and compensate for coordinate skew during // zoom. if (this.currentGesture_) { this.currentGesture_.cancel(); } // Multiplier variable, so that non-pixel-deltaModes are supported. // See LLK/scratch-blocks#1190. var multiplier = e.deltaMode === 0x1 ? Blockly.LINE_SCROLL_MULTIPLIER : 1; if (e.ctrlKey) { // The vertical scroll distance that corresponds to a click of a zoom button. var PIXELS_PER_ZOOM_STEP = 50; var delta = (-e.deltaY / PIXELS_PER_ZOOM_STEP) * multiplier; var position = Blockly.utils.mouseToSvg( e, this.getParentSvg(), this.getInverseScreenCTM() ); this.zoom(position.x, position.y, delta); } else { // This is a regular mouse wheel event - scroll the workspace // First hide the WidgetDiv without animation // (mouse scroll makes field out of place with div) Blockly.WidgetDiv.hide(true); Blockly.DropDownDiv.hideWithoutAnimation(); var x = this.scrollX - e.deltaX * multiplier; var y = this.scrollY - e.deltaY * multiplier; if (e.shiftKey && e.deltaX === 0) { // Scroll horizontally (based on vertical scroll delta) // This is needed as for some browser/system combinations which do not // set deltaX. See #1662. x = this.scrollX - e.deltaY * multiplier; y = this.scrollY; // Don't scroll vertically } this.startDragMetrics = this.getMetrics(); this.scroll(x, y); } e.preventDefault(); }; /** * Calculate the bounding box for the blocks on the workspace. * Coordinate system: workspace coordinates. * * @return {Object} Contains the position and size of the bounding box * containing the blocks on the workspace. */ Blockly.WorkspaceSvg.prototype.getBlocksBoundingBox = function () { var topBlocks = this.getTopBlocks(false); var topComments = this.getTopComments(false); var topElements = topBlocks.concat(topComments); // There are no blocks, return empty rectangle. if (!topElements.length) { return { x: 0, y: 0, width: 0, height: 0 }; } // Initialize boundary using the first block. var boundary = topElements[0].getBoundingRectangle(); // Start at 1 since the 0th block was used for initialization for (var i = 1; i < topElements.length; i++) { var blockBoundary = topElements[i].getBoundingRectangle(); if (blockBoundary.topLeft.x < boundary.topLeft.x) { boundary.topLeft.x = blockBoundary.topLeft.x; } if (blockBoundary.bottomRight.x > boundary.bottomRight.x) { boundary.bottomRight.x = blockBoundary.bottomRight.x; } if (blockBoundary.topLeft.y < boundary.topLeft.y) { boundary.topLeft.y = blockBoundary.topLeft.y; } if (blockBoundary.bottomRight.y > boundary.bottomRight.y) { boundary.bottomRight.y = blockBoundary.bottomRight.y; } } return { x: boundary.topLeft.x, y: boundary.topLeft.y, width: boundary.bottomRight.x - boundary.topLeft.x, height: boundary.bottomRight.y - boundary.topLeft.y }; }; /** * Clean up the workspace by ordering all the blocks in a column. */ Blockly.WorkspaceSvg.prototype.cleanUp = function () { this.setResizesEnabled(false); Blockly.Events.setGroup(true); var topBlocks = this.getTopBlocks(true); var cursorY = 0; for (var i = 0, block; (block = topBlocks[i]); i++) { var xy = block.getRelativeToSurfaceXY(); block.moveBy(-xy.x, cursorY - xy.y); block.snapToGrid(); cursorY = block.getRelativeToSurfaceXY().y + block.getHeightWidth().height + Blockly.BlockSvg.MIN_BLOCK_Y; } Blockly.Events.setGroup(false); this.setResizesEnabled(true); }; /** * Show the context menu for the workspace. * @param {!Event} e Mouse event. * @private */ Blockly.WorkspaceSvg.prototype.showContextMenu_ = function (e) { if (this.options.readOnly || this.isFlyout) { return; } var menuOptions = []; var topBlocks = this.getTopBlocks(true); var eventGroup = Blockly.utils.genUid(); var ws = this; // Options to undo/redo previous action. menuOptions.push(Blockly.ContextMenu.wsUndoOption(this)); menuOptions.push(Blockly.ContextMenu.wsRedoOption(this)); // Option to clean up blocks. if (this.scrollbar) { menuOptions.push( Blockly.ContextMenu.wsCleanupOption(this, topBlocks.length) ); } if (this.options.collapse) { var hasCollapsedBlocks = false; var hasExpandedBlocks = false; for (var i = 0; i < topBlocks.length; i++) { var block = topBlocks[i]; while (block) { if (block.isCollapsed()) { hasCollapsedBlocks = true; } else { hasExpandedBlocks = true; } block = block.getNextBlock(); } } menuOptions.push( Blockly.ContextMenu.wsCollapseOption(hasExpandedBlocks, topBlocks) ); menuOptions.push( Blockly.ContextMenu.wsExpandOption(hasCollapsedBlocks, topBlocks) ); } // Option to add a workspace comment. if (this.options.comments) { menuOptions.push(Blockly.ContextMenu.workspaceCommentOption(ws, e)); } // Option to delete all blocks. // Count the number of blocks that are deletable. var deleteList = Blockly.WorkspaceSvg.buildDeleteList_(topBlocks); // Scratch-specific: don't count shadow blocks in delete count var deleteCount = 0; for (var i = 0; i < deleteList.length; i++) { if (!deleteList[i].isShadow()) { deleteCount++; } } var DELAY = 10; function deleteNext() { Blockly.Events.setGroup(eventGroup); var block = deleteList.shift(); if (block) { if (block.workspace) { block.dispose(false, true); setTimeout(deleteNext, DELAY); } else { deleteNext(); } } Blockly.Events.setGroup(false); } var deleteOption = { text: deleteCount == 1 ? Blockly.Msg.DELETE_BLOCK : Blockly.Msg.DELETE_X_BLOCKS.replace('%1', String(deleteCount)), enabled: deleteCount > 0, callback: function () { if (ws.currentGesture_) { ws.currentGesture_.cancel(); } if (deleteCount < 2) { deleteNext(); } else { Blockly.confirm( Blockly.Msg.DELETE_ALL_BLOCKS.replace('%1', String(deleteCount)), function (ok) { if (ok) { deleteNext(); } } ); } } }; menuOptions.push(deleteOption); Blockly.ContextMenu.show(e, menuOptions, this.RTL); }; /** * Build a list of all deletable blocks that are reachable from the given * list of top blocks. * @param {!Array.<!Blockly.BlockSvg>} topBlocks The list of top blocks on the * workspace. * @return {!Array.<!Blockly.BlockSvg>} A list of deletable blocks on the * workspace. * @private */ Blockly.WorkspaceSvg.buildDeleteList_ = function (topBlocks) { var deleteList = []; function addDeletableBlocks(block) { if (block.isDeletable()) { deleteList = deleteList.concat(block.getDescendants(false)); } else { var children = block.getChildren(); for (var i = 0; i < children.length; i++) { addDeletableBlocks(children[i]); } } } for (var i = 0; i < topBlocks.length; i++) { addDeletableBlocks(topBlocks[i]); } return deleteList; }; /** * Modify the block tree on the existing toolbox. * @param {Node|string} tree DOM tree of blocks, or text representation of same. */ Blockly.WorkspaceSvg.prototype.updateToolbox = function (tree) { tree = Blockly.Options.parseToolboxTree(tree); if (!tree) { if (this.options.languageTree) { throw "Can't nullify an existing toolbox."; } return; // No change (null to null). } if (!this.options.languageTree) { throw "Existing toolbox is null. Can't create new toolbox."; } if (tree.getElementsByTagName('category').length) { if (!this.toolbox_) { throw "Existing toolbox has no categories. Can't change mode."; } this.options.languageTree = tree; this.toolbox_.populate_(tree); // this.toolbox_.position(); } else { if (!this.flyout_) { throw "Existing toolbox has categories. Can't change mode."; } this.options.languageTree = tree; this.flyout_.show(tree.childNodes); } }; /** * Mark this workspace as the currently focused main workspace. */ Blockly.WorkspaceSvg.prototype.markFocused = function () { if (this.options.parentWorkspace) { this.options.parentWorkspace.markFocused(); } else { Blockly.mainWorkspace = this; // We call e.preventDefault in many event handlers which means we // need to explicitly gr