scratch-blocks/core/workspace_svg.js
Paul Kaplan 88383d0998 Use the main workspaces stack glow filter for both flyout and toolbox
This fixes the stack glow filter not existing for both the "simple" and "category" modes of the workspace.
2018-06-01 10:18:42 -04:00

2232 lines
70 KiB
JavaScript

/**
* @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 `true` for visible workspaces and `false` for non-visible,
* or headless, workspaces.
* @type {boolean}
*/
Blockly.WorkspaceSvg.prototype.rendered = 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_;
};
/**
* 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();
}
};
/**
* 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.
* @return {?Blockly.VariableModel} The newly created variable.
* @package
*/
Blockly.WorkspaceSvg.prototype.createVariable = function(name, opt_type, opt_id) {
var variableInMap = (this.getVariable(name, opt_type) != null);
var newVar = Blockly.WorkspaceSvg.superClass_.createVariable.call(
this, name, opt_type, opt_id);
// 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();
}
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;
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;
var y = this.scrollY - e.deltaY;
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 (deleteList.length < 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 grab focus (e.g from a textarea) because
// the browser will not do it for us. How to do this is browser dependant.
this.setBrowserFocus();
}
};
/**
* Set the workspace to have focus in the browser.
* @private
*/
Blockly.WorkspaceSvg.prototype.setBrowserFocus = function() {
// Blur whatever was focused since explcitly grabbing focus below does not
// work in Edge.
if (document.activeElement) {
document.activeElement.blur();
}
try {
// Focus the workspace SVG - this is for Chrome and Firefox.
this.getParentSvg().focus();
} catch (e) {
// IE and Edge do not support focus on SVG elements. When that fails
// above, get the injectionDiv (the workspace's parent) and focus that
// instead. This doesn't work in Chrome.
try {
// In IE11, use setActive (which is IE only) so the page doesn't scroll
// to the workspace gaining focus.
this.getParentSvg().parentNode.setActive();
} catch (e) {
// setActive support was discontinued in Edge so when that fails, call
// focus instead.
this.getParentSvg().parentNode.focus();
}
}
};
/**
* Zooming the blocks centered in (x, y) coordinate with zooming in or out.
* @param {number} x X coordinate of center.
* @param {number} y Y coordinate of center.
* @param {number} amount Amount of zooming
* (negative zooms out and positive zooms in).
*/
Blockly.WorkspaceSvg.prototype.zoom = function(x, y, amount) {
var speed = this.options.zoomOptions.scaleSpeed;
var metrics = this.getMetrics();
var center = this.getParentSvg().createSVGPoint();
center.x = x;
center.y = y;
center = center.matrixTransform(this.getCanvas().getCTM().inverse());
x = center.x;
y = center.y;
var canvas = this.getCanvas();
// Scale factor.
var scaleChange = Math.pow(speed, amount);
// Clamp scale within valid range.
var newScale = this.scale * scaleChange;
if (newScale > this.options.zoomOptions.maxScale) {
scaleChange = this.options.zoomOptions.maxScale / this.scale;
} else if (newScale < this.options.zoomOptions.minScale) {
scaleChange = this.options.zoomOptions.minScale / this.scale;
}
if (this.scale == newScale) {
return; // No change in zoom.
}
if (this.scrollbar) {
var matrix = canvas.getCTM()
.translate(x * (1 - scaleChange), y * (1 - scaleChange))
.scale(scaleChange);
// newScale and matrix.a should be identical (within a rounding error).
// ScrollX and scrollY are in pixels.
this.scrollX = matrix.e - metrics.absoluteLeft;
this.scrollY = matrix.f - metrics.absoluteTop;
}
this.setScale(newScale);
// Hide the WidgetDiv without animation (zoom makes field out of place with div)
Blockly.WidgetDiv.hide(true);
Blockly.DropDownDiv.hideWithoutAnimation();
};
/**
* Zooming the blocks centered in the center of view with zooming in or out.
* @param {number} type Type of zooming (-1 zooming out and 1 zooming in).
*/
Blockly.WorkspaceSvg.prototype.zoomCenter = function(type) {
var metrics = this.getMetrics();
var x = metrics.viewWidth / 2;
var y = metrics.viewHeight / 2;
this.zoom(x, y, type);
};
/**
* Zoom the blocks to fit in the workspace if possible.
*/
Blockly.WorkspaceSvg.prototype.zoomToFit = function() {
var metrics = this.getMetrics();
var blocksBox = this.getBlocksBoundingBox();
var blocksWidth = blocksBox.width;
var blocksHeight = blocksBox.height;
if (!blocksWidth) {
return; // Prevents zooming to infinity.
}
var workspaceWidth = metrics.viewWidth;
var workspaceHeight = metrics.viewHeight;
if (this.flyout_) {
workspaceWidth -= this.flyout_.width_;
}
if (!this.scrollbar) {
// Origin point of 0,0 is fixed, blocks will not scroll to center.
blocksWidth += metrics.contentLeft;
blocksHeight += metrics.contentTop;
}
var ratioX = workspaceWidth / blocksWidth;
var ratioY = workspaceHeight / blocksHeight;
this.setScale(Math.min(ratioX, ratioY));
this.scrollCenter();
};
/**
* Center the workspace.
*/
Blockly.WorkspaceSvg.prototype.scrollCenter = function() {
if (!this.scrollbar) {
// Can't center a non-scrolling workspace.
console.warn('Tried to scroll a non-scrollable workspace.');
return;
}
// Hide the WidgetDiv without animation (zoom makes field out of place with div)
Blockly.WidgetDiv.hide(true);
Blockly.DropDownDiv.hideWithoutAnimation();
Blockly.hideChaff(false);
var metrics = this.getMetrics();
var x = (metrics.contentWidth - metrics.viewWidth) / 2;
if (this.flyout_) {
x -= this.flyout_.width_ / 2;
}
var y = (metrics.contentHeight - metrics.viewHeight) / 2;
this.scrollbar.set(x, y);
};
/**
* Scroll the workspace to center on the given block.
* @param {?string} id ID of block center on.
* @public
*/
Blockly.WorkspaceSvg.prototype.centerOnBlock = function(id) {
if (!this.scrollbar) {
console.warn('Tried to scroll a non-scrollable workspace.');
return;
}
var block = this.getBlockById(id);
if (!block) {
return;
}
// XY is in workspace coordinates.
var xy = block.getRelativeToSurfaceXY();
// Height/width is in workspace units.
var heightWidth = block.getHeightWidth();
// Find the enter of the block in workspace units.
var blockCenterY = xy.y + heightWidth.height / 2;
// In RTL the block's position is the top right of the block, not top left.
var multiplier = this.RTL ? -1 : 1;
var blockCenterX = xy.x + (multiplier * heightWidth.width / 2);
// Workspace scale, used to convert from workspace coordinates to pixels.
var scale = this.scale;
// Center in pixels. 0, 0 is at the workspace origin. These numbers may
// be negative.
var pixelX = blockCenterX * scale;
var pixelY = blockCenterY * scale;
var metrics = this.getMetrics();
// Scrolling to here would put the block in the top-left corner of the
// visible workspace.
var scrollToBlockX = pixelX - metrics.contentLeft;
var scrollToBlockY = pixelY - metrics.contentTop;
// viewHeight and viewWidth are in pixels.
var halfViewWidth = metrics.viewWidth / 2;
var halfViewHeight = metrics.viewHeight / 2;
// Put the block in the center of the visible workspace instead.
var scrollToCenterX = scrollToBlockX - halfViewWidth;
var scrollToCenterY = scrollToBlockY - halfViewHeight;
Blockly.hideChaff();
this.scrollbar.set(scrollToCenterX, scrollToCenterY);
};
/**
* Set the workspace's zoom factor.
* @param {number} newScale Zoom factor.
*/
Blockly.WorkspaceSvg.prototype.setScale = function(newScale) {
if (this.options.zoomOptions.maxScale &&
newScale > this.options.zoomOptions.maxScale) {
newScale = this.options.zoomOptions.maxScale;
} else if (this.options.zoomOptions.minScale &&
newScale < this.options.zoomOptions.minScale) {
newScale = this.options.zoomOptions.minScale;
}
this.scale = newScale;
if (this.grid_) {
this.grid_.update(this.scale);
}
if (this.scrollbar) {
this.scrollbar.resize();
} else {
this.translate(this.scrollX, this.scrollY);
}
Blockly.hideChaff(false);
if (this.flyout_) {
// No toolbox, resize flyout.
this.flyout_.reflow();
}
};
/**
* Scroll the workspace by a specified amount, keeping in the bounds.
* Be sure to set this.startDragMetrics with cached metrics before calling.
* @param {number} x Target X to scroll to
* @param {number} y Target Y to scroll to
*/
Blockly.WorkspaceSvg.prototype.scroll = function(x, y) {
var metrics = this.startDragMetrics; // Cached values
x = Math.min(x, -metrics.contentLeft);
y = Math.min(y, -metrics.contentTop);
x = Math.max(x, metrics.viewWidth - metrics.contentLeft -
metrics.contentWidth);
y = Math.max(y, metrics.viewHeight - metrics.contentTop -
metrics.contentHeight);
// When the workspace starts scrolling, hide the WidgetDiv without animation.
// This is to prevent a dispoal animation from happening in the wrong location.
Blockly.WidgetDiv.hide(true);
Blockly.DropDownDiv.hideWithoutAnimation();
// Move the scrollbars and the page will scroll automatically.
this.scrollbar.set(-x - metrics.contentLeft, -y - metrics.contentTop);
};
/**
* Update the workspace's stack glow radius to be proportional to scale.
* Ensures that stack glows always appear to be a fixed size.
*/
Blockly.WorkspaceSvg.prototype.updateStackGlowScale_ = function() {
// No such def in the flyout workspace.
if (this.options.stackGlowBlur) {
this.options.stackGlowBlur.setAttribute('stdDeviation',
Blockly.Colours.stackGlowSize / this.scale);
}
};
/**
* Get the dimensions of the given workspace component, in pixels.
* @param {Blockly.Toolbox|Blockly.Flyout} elem The element to get the
* dimensions of, or null. It should be a toolbox or flyout, and should
* implement getWidth() and getHeight().
* @return {!Object} An object containing width and height attributes, which
* will both be zero if elem did not exist.
* @private
*/
Blockly.WorkspaceSvg.getDimensionsPx_ = function(elem) {
var width = 0;
var height = 0;
if (elem) {
width = elem.getWidth();
height = elem.getHeight();
}
return {
width: width,
height: height
};
};
/**
* Get the content dimensions of the given workspace, taking into account
* whether or not it is scrollable and what size the workspace div is on screen.
* @param {!Blockly.WorkspaceSvg} ws The workspace to measure.
* @param {!Object} svgSize An object containing height and width attributes in
* CSS pixels. Together they specify the size of the visible workspace, not
* including areas covered up by the toolbox.
* @return {!Object} The dimensions of the contents of the given workspace, as
* an object containing at least
* - height and width in pixels
* - left and top in pixels relative to the workspace origin.
* @private
*/
Blockly.WorkspaceSvg.getContentDimensions_ = function(ws, svgSize) {
if (ws.scrollbar) {
return Blockly.WorkspaceSvg.getContentDimensionsBounded_(ws, svgSize);
} else {
return Blockly.WorkspaceSvg.getContentDimensionsExact_(ws);
}
};
/**
* Get the bounding box for all workspace contents, in pixels.
* @param {!Blockly.WorkspaceSvg} ws The workspace to inspect.
* @return {!Object} The dimensions of the contents of the given workspace, as
* an object containing
* - height and width in pixels
* - left, right, top and bottom in pixels relative to the workspace origin.
* @private
*/
Blockly.WorkspaceSvg.getContentDimensionsExact_ = function(ws) {
// Block bounding box is in workspace coordinates.
var blockBox = ws.getBlocksBoundingBox();
var scale = ws.scale;
// Convert to pixels.
var width = blockBox.width * scale;
var height = blockBox.height * scale;
var left = blockBox.x * scale;
var top = blockBox.y * scale;
return {
left: left,
top: top,
right: left + width,
bottom: top + height,
width: width,
height: height
};
};
/**
* Calculate the size of a scrollable workspace, which should include room for a
* half screen border around the workspace contents.
* @param {!Blockly.WorkspaceSvg} ws The workspace to measure.
* @param {!Object} svgSize An object containing height and width attributes in
* CSS pixels. Together they specify the size of the visible workspace, not
* including areas covered up by the toolbox.
* @return {!Object} The dimensions of the contents of the given workspace, as
* an object containing
* - height and width in pixels
* - left and top in pixels relative to the workspace origin.
* @private
*/
Blockly.WorkspaceSvg.getContentDimensionsBounded_ = function(ws, svgSize) {
var content = Blockly.WorkspaceSvg.getContentDimensionsExact_(ws);
// View height and width are both in pixels, and are the same as the SVG size.
var viewWidth = svgSize.width;
var viewHeight = svgSize.height;
var halfWidth = viewWidth / 2;
var halfHeight = viewHeight / 2;
// Add a border around the content that is at least half a screenful wide.
// Ensure border is wide enough that blocks can scroll over entire screen.
var left = Math.min(content.left - halfWidth, content.right - viewWidth);
var right = Math.max(content.right + halfWidth, content.left + viewWidth);
var top = Math.min(content.top - halfHeight, content.bottom - viewHeight);
var bottom = Math.max(content.bottom + halfHeight, content.top + viewHeight);
var dimensions = {
left: left,
top: top,
height: bottom - top,
width: right - left
};
return dimensions;
};
/**
* Return an object with all the metrics required to size scrollbars for a
* top level workspace. The following properties are computed:
* Coordinate system: pixel coordinates.
* .viewHeight: Height of the visible rectangle,
* .viewWidth: Width of the visible rectangle,
* .contentHeight: Height of the contents,
* .contentWidth: Width of the content,
* .viewTop: Offset of top edge of visible rectangle from parent,
* .viewLeft: Offset of left edge of visible rectangle from parent,
* .contentTop: Offset of the top-most content from the y=0 coordinate,
* .contentLeft: Offset of the left-most content from the x=0 coordinate.
* .absoluteTop: Top-edge of view.
* .absoluteLeft: Left-edge of view.
* .toolboxWidth: Width of toolbox, if it exists. Otherwise zero.
* .toolboxHeight: Height of toolbox, if it exists. Otherwise zero.
* .flyoutWidth: Width of the flyout if it is always open. Otherwise zero.
* .flyoutHeight: Height of flyout if it is always open. Otherwise zero.
* .toolboxPosition: Top, bottom, left or right.
* @return {!Object} Contains size and position metrics of a top level
* workspace.
* @private
* @this Blockly.WorkspaceSvg
*/
Blockly.WorkspaceSvg.getTopLevelWorkspaceMetrics_ = function() {
var toolboxDimensions =
Blockly.WorkspaceSvg.getDimensionsPx_(this.toolbox_);
var flyoutDimensions =
Blockly.WorkspaceSvg.getDimensionsPx_(this.flyout_);
// Contains height and width in CSS pixels.
// svgSize is equivalent to the size of the injectionDiv at this point.
var svgSize = Blockly.svgSize(this.getParentSvg());
if (this.toolbox_) {
if (this.toolboxPosition == Blockly.TOOLBOX_AT_TOP ||
this.toolboxPosition == Blockly.TOOLBOX_AT_BOTTOM) {
svgSize.height -= toolboxDimensions.height;
} else if (this.toolboxPosition == Blockly.TOOLBOX_AT_LEFT ||
this.toolboxPosition == Blockly.TOOLBOX_AT_RIGHT) {
svgSize.width -= toolboxDimensions.width;
}
}
// svgSize is now the space taken up by the Blockly workspace, not including
// the toolbox.
var contentDimensions =
Blockly.WorkspaceSvg.getContentDimensions_(this, svgSize);
var absoluteLeft = 0;
if (this.toolbox_ && this.toolboxPosition == Blockly.TOOLBOX_AT_LEFT) {
absoluteLeft = toolboxDimensions.width;
}
var absoluteTop = 0;
if (this.toolbox_ && this.toolboxPosition == Blockly.TOOLBOX_AT_TOP) {
absoluteTop = toolboxDimensions.height;
}
var metrics = {
contentHeight: contentDimensions.height,
contentWidth: contentDimensions.width,
contentTop: contentDimensions.top,
contentLeft: contentDimensions.left,
viewHeight: svgSize.height,
viewWidth: svgSize.width,
viewTop: -this.scrollY, // Must be in pixels, somehow.
viewLeft: -this.scrollX, // Must be in pixels, somehow.
absoluteTop: absoluteTop,
absoluteLeft: absoluteLeft,
toolboxWidth: toolboxDimensions.width,
toolboxHeight: toolboxDimensions.height,
flyoutWidth: flyoutDimensions.width,
flyoutHeight: flyoutDimensions.height,
toolboxPosition: this.toolboxPosition
};
return metrics;
};
/**
* Sets the X/Y translations of a top level workspace to match the scrollbars.
* @param {!Object} xyRatio Contains an x and/or y property which is a float
* between 0 and 1 specifying the degree of scrolling.
* @private
* @this Blockly.WorkspaceSvg
*/
Blockly.WorkspaceSvg.setTopLevelWorkspaceMetrics_ = function(xyRatio) {
if (!this.scrollbar) {
throw 'Attempt to set top level workspace scroll without scrollbars.';
}
var metrics = this.getMetrics();
if (goog.isNumber(xyRatio.x)) {
this.scrollX = -metrics.contentWidth * xyRatio.x - metrics.contentLeft;
}
if (goog.isNumber(xyRatio.y)) {
this.scrollY = -metrics.contentHeight * xyRatio.y - metrics.contentTop;
}
var x = this.scrollX + metrics.absoluteLeft;
var y = this.scrollY + metrics.absoluteTop;
this.translate(x, y);
if (this.grid_) {
this.grid_.moveTo(x, y);
}
};
/**
* Update whether this workspace has resizes enabled.
* If enabled, workspace will resize when appropriate.
* If disabled, workspace will not resize until re-enabled.
* Use to avoid resizing during a batch operation, for performance.
* @param {boolean} enabled Whether resizes should be enabled.
*/
Blockly.WorkspaceSvg.prototype.setResizesEnabled = function(enabled) {
var reenabled = (!this.resizesEnabled_ && enabled);
this.resizesEnabled_ = enabled;
if (reenabled) {
// Newly enabled. Trigger a resize.
this.resizeContents();
}
};
/**
* Update whether this workspace has toolbox refreshes enabled.
* If enabled, the toolbox will refresh when appropriate.
* If disabled, workspace will not refresh until re-enabled.
* Use to avoid refreshing during a batch operation, for performance.
* @param {boolean} enabled Whether refreshes should be enabled.
*/
Blockly.WorkspaceSvg.prototype.setToolboxRefreshEnabled = function(enabled) {
var reenabled = (!this.toolboxRefreshEnabled_ && enabled);
this.toolboxRefreshEnabled_ = enabled;
if (reenabled) {
// Newly enabled. Trigger a refresh.
this.refreshToolboxSelection_();
}
};
/**
* Dispose of all blocks in workspace, with an optimization to prevent resizes.
*/
Blockly.WorkspaceSvg.prototype.clear = function() {
this.setResizesEnabled(false);
Blockly.WorkspaceSvg.superClass_.clear.call(this);
this.setResizesEnabled(true);
};
/**
* Register a callback function associated with a given key, for clicks on
* buttons and labels in the flyout.
* For instance, a button specified by the XML
* <button text="create variable" callbackKey="CREATE_VARIABLE"></button>
* should be matched by a call to
* registerButtonCallback("CREATE_VARIABLE", yourCallbackFunction).
* @param {string} key The name to use to look up this function.
* @param {function(!Blockly.FlyoutButton)} func The function to call when the
* given button is clicked.
*/
Blockly.WorkspaceSvg.prototype.registerButtonCallback = function(key, func) {
goog.asserts.assert(goog.isFunction(func),
'Button callbacks must be functions.');
this.flyoutButtonCallbacks_[key] = func;
};
/**
* Get the callback function associated with a given key, for clicks on buttons
* and labels in the flyout.
* @param {string} key The name to use to look up the function.
* @return {?function(!Blockly.FlyoutButton)} The function corresponding to the
* given key for this workspace; null if no callback is registered.
*/
Blockly.WorkspaceSvg.prototype.getButtonCallback = function(key) {
var result = this.flyoutButtonCallbacks_[key];
return result ? result : null;
};
/**
* Remove a callback for a click on a button in the flyout.
* @param {string} key The name associated with the callback function.
*/
Blockly.WorkspaceSvg.prototype.removeButtonCallback = function(key) {
this.flyoutButtonCallbacks_[key] = null;
};
/**
* Register a callback function associated with a given key, for populating
* custom toolbox categories in this workspace. See the variable and procedure
* categories as an example.
* @param {string} key The name to use to look up this function.
* @param {function(!Blockly.Workspace):!Array.<!Element>} func The function to
* call when the given toolbox category is opened.
*/
Blockly.WorkspaceSvg.prototype.registerToolboxCategoryCallback = function(key,
func) {
goog.asserts.assert(goog.isFunction(func),
'Toolbox category callbacks must be functions.');
this.toolboxCategoryCallbacks_[key] = func;
};
/**
* Get the callback function associated with a given key, for populating
* custom toolbox categories in this workspace.
* @param {string} key The name to use to look up the function.
* @return {?function(!Blockly.Workspace):!Array.<!Element>} The function
* corresponding to the given key for this workspace, or null if no function
* is registered.
*/
Blockly.WorkspaceSvg.prototype.getToolboxCategoryCallback = function(key) {
var result = this.toolboxCategoryCallbacks_[key];
return result ? result : null;
};
/**
* Remove a callback for a click on a custom category's name in the toolbox.
* @param {string} key The name associated with the callback function.
*/
Blockly.WorkspaceSvg.prototype.removeToolboxCategoryCallback = function(key) {
this.toolboxCategoryCallbacks_[key] = null;
};
/**
* Look up the gesture that is tracking this touch stream on this workspace.
* May create a new gesture.
* @param {!Event} e Mouse event or touch event
* @return {Blockly.Gesture} The gesture that is tracking this touch stream,
* or null if no valid gesture exists.
* @package
*/
Blockly.WorkspaceSvg.prototype.getGesture = function(e) {
var isStart = (e.type == 'mousedown' || e.type == 'touchstart');
var gesture = this.currentGesture_;
if (gesture) {
if (isStart && gesture.hasStarted()) {
console.warn('tried to start the same gesture twice');
// That's funny. We must have missed a mouse up.
// Cancel it, rather than try to retrieve all of the state we need.
gesture.cancel();
return null;
}
return gesture;
}
// No gesture existed on this workspace, but this looks like the start of a
// new gesture.
if (isStart) {
this.currentGesture_ = new Blockly.Gesture(e, this);
return this.currentGesture_;
}
// No gesture existed and this event couldn't be the start of a new gesture.
return null;
};
/**
* Clear the reference to the current gesture.
* @package
*/
Blockly.WorkspaceSvg.prototype.clearGesture = function() {
this.currentGesture_ = null;
};
/**
* Cancel the current gesture, if one exists.
* @package
*/
Blockly.WorkspaceSvg.prototype.cancelCurrentGesture = function() {
if (this.currentGesture_) {
this.currentGesture_.cancel();
}
};
/**
* Don't even think about using this function before talking to rachel-fenichel.
*
* Force a drag to start without clicking and dragging the block itself. Used
* to attach duplicated blocks to the mouse pointer.
* @param {!Object} fakeEvent An object with the properties needed to start a
* drag, including clientX and clientY.
* @param {!Blockly.BlockSvg} block The block to start dragging.
* @package
*/
Blockly.WorkspaceSvg.prototype.startDragWithFakeEvent = function(fakeEvent,
block) {
Blockly.Touch.clearTouchIdentifier();
Blockly.Touch.checkTouchIdentifier(fakeEvent);
var gesture = block.workspace.getGesture(fakeEvent);
gesture.forceStartBlockDrag(fakeEvent, block);
};
/**
* Get the audio manager for this workspace.
* @return {Blockly.WorkspaceAudio} The audio manager for this workspace.
*/
Blockly.WorkspaceSvg.prototype.getAudioManager = function() {
return this.audioManager_;
};
/**
* Get the grid object for this workspace, or null if there is none.
* @return {Blockly.Grid} The grid object for this workspace.
* @package
*/
Blockly.WorkspaceSvg.prototype.getGrid = function() {
return this.grid_;
};
// Export symbols that would otherwise be renamed by Closure compiler.
Blockly.WorkspaceSvg.prototype['setVisible'] =
Blockly.WorkspaceSvg.prototype.setVisible;