diff options
Diffstat (limited to 'blockly/core/workspace_svg.js')
-rw-r--r-- | blockly/core/workspace_svg.js | 1401 |
1 files changed, 1401 insertions, 0 deletions
diff --git a/blockly/core/workspace_svg.js b/blockly/core/workspace_svg.js new file mode 100644 index 0000000..a3c0b53 --- /dev/null +++ b/blockly/core/workspace_svg.js @@ -0,0 +1,1401 @@ +/** + * @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.ConnectionDB'); +goog.require('Blockly.constants'); +goog.require('Blockly.Options'); +goog.require('Blockly.ScrollbarPair'); +goog.require('Blockly.Trashcan'); +goog.require('Blockly.Workspace'); +goog.require('Blockly.Xml'); +goog.require('Blockly.ZoomControls'); + +goog.require('goog.dom'); +goog.require('goog.math.Coordinate'); +goog.require('goog.userAgent'); + + +/** + * Class for a workspace. This is an onscreen area with optional trashcan, + * scrollbars, bubbles, and dragging. + * @param {!Blockly.Options} options Dictionary of options. + * @extends {Blockly.Workspace} + * @constructor + */ +Blockly.WorkspaceSvg = function(options) { + 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); + + /** + * Database of pre-loaded sounds. + * @private + * @const + */ + this.SOUNDS_ = Object.create(null); +}; +goog.inherits(Blockly.WorkspaceSvg, Blockly.Workspace); + +/** + * Wrapper function called when a resize event occurs. + * @type {Array.<!Array>} Data that can be passed to unbindEvent_ + */ +Blockly.WorkspaceSvg.prototype.resizeHandlerWrapper_ = null; + +/** + * Svg workspaces are user-visible (as opposed to a headless workspace). + * @type {boolean} True if visible. False if headless. + */ +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; + +/** + * Is this workspace currently being dragged around? + * DRAG_NONE - No drag operation. + * DRAG_BEGIN - Still inside the initial DRAG_RADIUS. + * DRAG_FREE - Workspace has been dragged further than DRAG_RADIUS. + * @private + */ +Blockly.WorkspaceSvg.prototype.dragMode_ = Blockly.DRAG_NONE; + +/** + * Current horizontal scrolling offset. + * @type {number} + */ +Blockly.WorkspaceSvg.prototype.scrollX = 0; + +/** + * Current vertical scrolling offset. + * @type {number} + */ +Blockly.WorkspaceSvg.prototype.scrollY = 0; + +/** + * Horizontal scroll value when scrolling started. + * @type {number} + */ +Blockly.WorkspaceSvg.prototype.startScrollX = 0; + +/** + * Vertical scroll value when scrolling started. + * @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; + +/** + * Time that the last sound was played. + * @type {Date} + * @private + */ +Blockly.WorkspaceSvg.prototype.lastSound_ = 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; + +/** + * Inverted screen CTM, for use in mouseToSvg. + * @type {SVGMatrix} + * @private + */ +Blockly.WorkspaceSvg.prototype.inverseScreenCTM_ = null; + +/** + * Getter for the inverted screen CTM. + * @return {SVGMatrix} The matrix to use in mouseToSvg + */ +Blockly.WorkspaceSvg.prototype.getInverseScreenCTM = function() { + return this.inverseScreenCTM_; +}; + +/** + * Update the inverted screen CTM. + */ +Blockly.WorkspaceSvg.prototype.updateInverseScreenCTM = function() { + this.inverseScreenCTM_ = this.getParentSvg().getScreenCTM().inverse(); +}; + +/** + * 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> + * [Scrollbars may go here] + * </g> + * @type {SVGElement} + */ + this.svgGroup_ = Blockly.createSvgElement('g', + {'class': 'blocklyWorkspace'}, null); + if (opt_backgroundClass) { + /** @type {SVGElement} */ + this.svgBackground_ = Blockly.createSvgElement('rect', + {'height': '100%', 'width': '100%', 'class': opt_backgroundClass}, + this.svgGroup_); + if (opt_backgroundClass == 'blocklyMainBackground') { + this.svgBackground_.style.fill = + 'url(#' + this.options.gridPattern.id + ')'; + } + } + /** @type {SVGElement} */ + this.svgBlockCanvas_ = Blockly.createSvgElement('g', + {'class': 'blocklyBlockCanvas'}, this.svgGroup_, this); + /** @type {SVGElement} */ + this.svgBubbleCanvas_ = Blockly.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) { + bottom = this.addZoomControls_(bottom); + } + + if (!this.isFlyout) { + Blockly.bindEvent_(this.svgGroup_, 'mousedown', this, this.onMouseDown_); + var thisWorkspace = this; + Blockly.bindEvent_(this.svgGroup_, 'touchstart', null, + function(e) {Blockly.longStart_(e, thisWorkspace);}); + if (this.options.zoomOptions && this.options.zoomOptions.wheel) { + // Mouse-wheel. + Blockly.bindEvent_(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) { + this.toolbox_ = new Blockly.Toolbox(this); + } else if (this.options.languageTree) { + this.addFlyout_(); + } + this.updateGridPattern_(); + this.recordDeleteAreas(); + 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; + 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.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. + * @private + */ +Blockly.WorkspaceSvg.prototype.addFlyout_ = function() { + var workspaceOptions = { + disabledPatternId: this.options.disabledPatternId, + parentWorkspace: this, + RTL: this.RTL, + horizontalLayout: this.horizontalLayout, + toolboxPosition: this.options.toolboxPosition + }; + /** @type {Blockly.Flyout} */ + this.flyout_ = new Blockly.Flyout(workspaceOptions); + this.flyout_.autoClose = false; + var svgFlyout = this.flyout_.createDom(); + this.svgGroup_.insertBefore(svgFlyout, this.svgBlockCanvas_); +}; + +/** + * 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.recordDeleteAreas(); +}; + +/** + * 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.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) { + var translation = 'translate(' + x + ',' + y + ') ' + + 'scale(' + this.scale + ')'; + this.svgBlockCanvas_.setAttribute('transform', translation); + this.svgBubbleCanvas_.setAttribute('transform', translation); +}; + +/** + * 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) { + 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(); + if (this.toolbox_) { + this.toolbox_.position(); + } + } else { + Blockly.hideChaff(true); + } +}; + +/** + * 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); + } +}; + +/** + * Turn the visual trace functionality on or off. + * @param {boolean} armed True if the trace should be on. + */ +Blockly.WorkspaceSvg.prototype.traceOn = function(armed) { + this.traceOn_ = armed; + if (this.traceWrapper_) { + Blockly.unbindEvent_(this.traceWrapper_); + this.traceWrapper_ = null; + } + if (armed) { + this.traceWrapper_ = Blockly.bindEvent_(this.svgBlockCanvas_, + 'blocklySelectChange', this, function() {this.traceOn_ = false;}); + } +}; + +/** + * Highlight a block in the workspace. + * @param {?string} id ID of block to find. + */ +Blockly.WorkspaceSvg.prototype.highlightBlock = function(id) { + if (this.traceOn_ && Blockly.dragMode_ != Blockly.DRAG_NONE) { + // The blocklySelectChange event normally prevents this, but sometimes + // there is a race condition on fast-executing apps. + this.traceOn(false); + } + if (!this.traceOn_) { + return; + } + var block = null; + if (id) { + block = this.getBlockById(id); + if (!block) { + return; + } + } + // Temporary turn off the listener for selection changes, so that we don't + // trip the monitor for detecting user activity. + this.traceOn(false); + // Select the current block. + if (block) { + block.select(); + } else if (Blockly.selected) { + Blockly.selected.unselect(); + } + // Restore the monitor for user activity after the selection event has fired. + var thisWorkspace = this; + setTimeout(function() {thisWorkspace.traceOn(true);}, 1); +}; + +/** + * Paste the provided block onto the workspace. + * @param {!Element} xmlBlock XML block element. + */ +Blockly.WorkspaceSvg.prototype.paste = function(xmlBlock) { + if (!this.rendered || xmlBlock.getElementsByTagName('block').length >= + this.remainingCapacity()) { + return; + } + Blockly.terminateDrag_(); // Dragging while pasting? No. + Blockly.Events.disable(); + try { + var block = Blockly.Xml.domToBlock(xmlBlock, this); + // 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.Create(block)); + } + block.select(); +}; + +/** + * Create a new variable with the given name. Update the flyout to show the new + * variable immediately. + * TODO: #468 + * @param {string} name The new variable's name. + */ +Blockly.WorkspaceSvg.prototype.createVariable = function(name) { + Blockly.WorkspaceSvg.superClass_.createVariable.call(this, name); + if (this.toolbox_ && this.toolbox_.flyout_) { + this.toolbox_.refreshSelection(); + } +}; + +/** + * Make a list of all the delete areas for this workspace. + */ +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; + } +}; + +/** + * Is the mouse event over a delete area (toolbox or non-closing flyout)? + * Opens or closes the trashcan and sets the cursor as a side effect. + * @param {!Event} e Mouse move event. + * @return {boolean} True if event is in a delete area. + */ +Blockly.WorkspaceSvg.prototype.isDeleteArea = function(e) { + var xy = new goog.math.Coordinate(e.clientX, e.clientY); + if (this.deleteAreaTrash_) { + if (this.deleteAreaTrash_.contains(xy)) { + this.trashcan.setOpen_(true); + Blockly.Css.setCursor(Blockly.Css.Cursor.DELETE); + return true; + } + this.trashcan.setOpen_(false); + } + if (this.deleteAreaToolbox_) { + if (this.deleteAreaToolbox_.contains(xy)) { + Blockly.Css.setCursor(Blockly.Css.Cursor.DELETE); + return true; + } + } + Blockly.Css.setCursor(Blockly.Css.Cursor.CLOSED); + return false; +}; + +/** + * Handle a mouse-down on SVG drawing surface. + * @param {!Event} e Mouse down event. + * @private + */ +Blockly.WorkspaceSvg.prototype.onMouseDown_ = function(e) { + this.markFocused(); + if (Blockly.isTargetInput_(e)) { + return; + } + Blockly.terminateDrag_(); // In case mouse-up event was lost. + Blockly.hideChaff(); + var isTargetWorkspace = e.target && e.target.nodeName && + (e.target.nodeName.toLowerCase() == 'svg' || + e.target == this.svgBackground_); + if (isTargetWorkspace && Blockly.selected && !this.options.readOnly) { + // Clicking on the document clears the selection. + Blockly.selected.unselect(); + } + if (Blockly.isRightButton(e)) { + // Right-click. + this.showContextMenu_(e); + } else if (this.scrollbar) { + this.dragMode_ = Blockly.DRAG_BEGIN; + // Record the current mouse position. + this.startDragMouseX = e.clientX; + this.startDragMouseY = e.clientY; + this.startDragMetrics = this.getMetrics(); + this.startScrollX = this.scrollX; + this.startScrollY = this.scrollY; + + // If this is a touch event then bind to the mouseup so workspace drag mode + // is turned off and double move events are not performed on a block. + // See comment in inject.js Blockly.init_ as to why mouseup events are + // bound to the document instead of the SVG's surface. + if ('mouseup' in Blockly.bindEvent_.TOUCH_MAP) { + Blockly.onTouchUpWrapper_ = Blockly.onTouchUpWrapper_ || []; + Blockly.onTouchUpWrapper_ = Blockly.onTouchUpWrapper_.concat( + Blockly.bindEvent_(document, 'mouseup', null, Blockly.onMouseUp_)); + } + Blockly.onMouseMoveWrapper_ = Blockly.onMouseMoveWrapper_ || []; + Blockly.onMouseMoveWrapper_ = Blockly.onMouseMoveWrapper_.concat( + Blockly.bindEvent_(document, 'mousemove', null, Blockly.onMouseMove_)); + } + // This event has been handled. No need to bubble up to the document. + e.stopPropagation(); + e.preventDefault(); +}; + +/** + * 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.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.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 Blockly.dragMode_ == Blockly.DRAG_FREE || + (Blockly.Flyout.startFlyout_ && + Blockly.Flyout.startFlyout_.dragMode_ == Blockly.DRAG_FREE) || + this.dragMode_ == Blockly.DRAG_FREE; +}; + +/** + * Handle a mouse-wheel on SVG drawing surface. + * @param {!Event} e Mouse wheel event. + * @private + */ +Blockly.WorkspaceSvg.prototype.onMouseWheel_ = function(e) { + // TODO: Remove terminateDrag and compensate for coordinate skew during zoom. + Blockly.terminateDrag_(); + var delta = e.deltaY > 0 ? -1 : 1; + var position = Blockly.mouseToSvg(e, this.getParentSvg(), + this.getInverseScreenCTM()); + this.zoom(position.x, position.y, delta); + e.preventDefault(); +}; + +/** + * Calculate the bounding box for the blocks on the workspace. + * + * @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); + // There are no blocks, return empty rectangle. + if (!topBlocks.length) { + return {x: 0, y: 0, width: 0, height: 0}; + } + + // Initialize boundary using the first block. + var boundary = topBlocks[0].getBoundingRectangle(); + + // Start at 1 since the 0th block was used for initialization + for (var i = 1; i < topBlocks.length; i++) { + var blockBoundary = topBlocks[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() { + 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); + // Fire an event to allow scrollbars to resize. + this.resizeContents(); +}; + +/** + * 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.genUid(); + + // Options to undo/redo previous action. + var undoOption = {}; + undoOption.text = Blockly.Msg.UNDO; + undoOption.enabled = this.undoStack_.length > 0; + undoOption.callback = this.undo.bind(this, false); + menuOptions.push(undoOption); + var redoOption = {}; + redoOption.text = Blockly.Msg.REDO; + redoOption.enabled = this.redoStack_.length > 0; + redoOption.callback = this.undo.bind(this, true); + menuOptions.push(redoOption); + + // Option to clean up blocks. + if (this.scrollbar) { + var cleanOption = {}; + cleanOption.text = Blockly.Msg.CLEAN_UP; + cleanOption.enabled = topBlocks.length > 1; + cleanOption.callback = this.cleanUp.bind(this); + menuOptions.push(cleanOption); + } + + // Add a little animation to collapsing and expanding. + var DELAY = 10; + 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(); + } + } + + /** + * Option to collapse or expand top blocks. + * @param {boolean} shouldCollapse Whether a block should collapse. + * @private + */ + var toggleOption = function(shouldCollapse) { + var ms = 0; + for (var i = 0; i < topBlocks.length; i++) { + var block = topBlocks[i]; + while (block) { + setTimeout(block.setCollapsed.bind(block, shouldCollapse), ms); + block = block.getNextBlock(); + ms += DELAY; + } + } + }; + + // Option to collapse top blocks. + var collapseOption = {enabled: hasExpandedBlocks}; + collapseOption.text = Blockly.Msg.COLLAPSE_ALL; + collapseOption.callback = function() { + toggleOption(true); + }; + menuOptions.push(collapseOption); + + // Option to expand top blocks. + var expandOption = {enabled: hasCollapsedBlocks}; + expandOption.text = Blockly.Msg.EXPAND_ALL; + expandOption.callback = function() { + toggleOption(false); + }; + menuOptions.push(expandOption); + } + + // Option to delete all blocks. + // Count the number of blocks that are deletable. + var deleteList = []; + function addDeletableBlocks(block) { + if (block.isDeletable()) { + deleteList = deleteList.concat(block.getDescendants()); + } 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]); + } + + 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: deleteList.length == 1 ? Blockly.Msg.DELETE_BLOCK : + Blockly.Msg.DELETE_X_BLOCKS.replace('%1', String(deleteList.length)), + enabled: deleteList.length > 0, + callback: function() { + if (deleteList.length < 2 || + window.confirm(Blockly.Msg.DELETE_ALL_BLOCKS.replace('%1', + String(deleteList.length)))) { + deleteNext(); + } + } + }; + menuOptions.push(deleteOption); + + Blockly.ContextMenu.show(e, menuOptions, this.RTL); +}; + +/** + * Load an audio file. Cache it, ready for instantaneous playing. + * @param {!Array.<string>} filenames List of file types in decreasing order of + * preference (i.e. increasing size). E.g. ['media/go.mp3', 'media/go.wav'] + * Filenames include path from Blockly's root. File extensions matter. + * @param {string} name Name of sound. + * @private + */ +Blockly.WorkspaceSvg.prototype.loadAudio_ = function(filenames, name) { + if (!filenames.length) { + return; + } + try { + var audioTest = new window['Audio'](); + } catch (e) { + // No browser support for Audio. + // IE can throw an error even if the Audio object exists. + return; + } + var sound; + for (var i = 0; i < filenames.length; i++) { + var filename = filenames[i]; + var ext = filename.match(/\.(\w+)$/); + if (ext && audioTest.canPlayType('audio/' + ext[1])) { + // Found an audio format we can play. + sound = new window['Audio'](filename); + break; + } + } + if (sound && sound.play) { + this.SOUNDS_[name] = sound; + } +}; + +/** + * Preload all the audio files so that they play quickly when asked for. + * @private + */ +Blockly.WorkspaceSvg.prototype.preloadAudio_ = function() { + for (var name in this.SOUNDS_) { + var sound = this.SOUNDS_[name]; + sound.volume = .01; + sound.play(); + sound.pause(); + // iOS can only process one sound at a time. Trying to load more than one + // corrupts the earlier ones. Just load one and leave the others uncached. + if (goog.userAgent.IPAD || goog.userAgent.IPHONE) { + break; + } + } +}; + +/** + * Play a named sound at specified volume. If volume is not specified, + * use full volume (1). + * @param {string} name Name of sound. + * @param {number=} opt_volume Volume of sound (0-1). + */ +Blockly.WorkspaceSvg.prototype.playAudio = function(name, opt_volume) { + var sound = this.SOUNDS_[name]; + if (sound) { + // Don't play one sound on top of another. + var now = new Date; + if (now - this.lastSound_ < Blockly.SOUND_LIMIT) { + return; + } + this.lastSound_ = now; + var mySound; + var ie9 = goog.userAgent.DOCUMENT_MODE && + goog.userAgent.DOCUMENT_MODE === 9; + if (ie9 || goog.userAgent.IPAD || goog.userAgent.ANDROID) { + // Creating a new audio node causes lag in IE9, Android and iPad. Android + // and IE9 refetch the file from the server, iPad uses a singleton audio + // node which must be deleted and recreated for each new audio tag. + mySound = sound; + } else { + mySound = sound.cloneNode(); + } + mySound.volume = (opt_volume === undefined ? 1 : opt_volume); + mySound.play(); + } else if (this.options.parentWorkspace) { + // Maybe a workspace on a lower level knows about this sound. + this.options.parentWorkspace.playAudio(name, opt_volume); + } +}; + +/** + * 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_.addColour_(); + } 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; + } +}; + +/** + * 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} type Type of zooming (-1 zooming out and 1 zooming in). + */ +Blockly.WorkspaceSvg.prototype.zoom = function(x, y, type) { + 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 = (type == 1) ? speed : 1 / speed; + // 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). + this.scrollX = matrix.e - metrics.absoluteLeft; + this.scrollY = matrix.f - metrics.absoluteTop; + } + this.setScale(newScale); +}; + +/** + * 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) { + // Orgin 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. + return; + } + 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); +}; + +/** + * 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; + this.updateGridPattern_(); + 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(); + } +}; + +/** + * Updates the grid pattern. + * @private + */ +Blockly.WorkspaceSvg.prototype.updateGridPattern_ = function() { + if (!this.options.gridPattern) { + return; // No grid. + } + // MSIE freaks if it sees a 0x0 pattern, so set empty patterns to 100x100. + var safeSpacing = (this.options.gridOptions['spacing'] * this.scale) || 100; + this.options.gridPattern.setAttribute('width', safeSpacing); + this.options.gridPattern.setAttribute('height', safeSpacing); + var half = Math.floor(this.options.gridOptions['spacing'] / 2) + 0.5; + var start = half - this.options.gridOptions['length'] / 2; + var end = half + this.options.gridOptions['length'] / 2; + var line1 = this.options.gridPattern.firstChild; + var line2 = line1 && line1.nextSibling; + half *= this.scale; + start *= this.scale; + end *= this.scale; + if (line1) { + line1.setAttribute('stroke-width', this.scale); + line1.setAttribute('x1', start); + line1.setAttribute('y1', half); + line1.setAttribute('x2', end); + line1.setAttribute('y2', half); + } + if (line2) { + line2.setAttribute('stroke-width', this.scale); + line2.setAttribute('x1', half); + line2.setAttribute('y1', start); + line2.setAttribute('x2', half); + line2.setAttribute('y2', end); + } +}; + + +/** + * Return an object with all the metrics required to size scrollbars for a + * top level workspace. The following properties are computed: + * .viewHeight: Height of the visible rectangle, + * .viewWidth: Width of the visible rectangle, + * .contentHeight: Height of the contents, + * .contentWidth: Width of the 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 + */ +Blockly.WorkspaceSvg.getTopLevelWorkspaceMetrics_ = function() { + var svgSize = Blockly.svgSize(this.getParentSvg()); + if (this.toolbox_) { + if (this.toolboxPosition == Blockly.TOOLBOX_AT_TOP || + this.toolboxPosition == Blockly.TOOLBOX_AT_BOTTOM) { + svgSize.height -= this.toolbox_.getHeight(); + } else if (this.toolboxPosition == Blockly.TOOLBOX_AT_LEFT || + this.toolboxPosition == Blockly.TOOLBOX_AT_RIGHT) { + svgSize.width -= this.toolbox_.getWidth(); + } + } + // Set the margin to match the flyout's margin so that the workspace does + // not jump as blocks are added. + var MARGIN = Blockly.Flyout.prototype.CORNER_RADIUS - 1; + var viewWidth = svgSize.width - MARGIN; + var viewHeight = svgSize.height - MARGIN; + var blockBox = this.getBlocksBoundingBox(); + + // Fix scale. + var contentWidth = blockBox.width * this.scale; + var contentHeight = blockBox.height * this.scale; + var contentX = blockBox.x * this.scale; + var contentY = blockBox.y * this.scale; + if (this.scrollbar) { + // 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 leftEdge = Math.min(contentX - viewWidth / 2, + contentX + contentWidth - viewWidth); + var rightEdge = Math.max(contentX + contentWidth + viewWidth / 2, + contentX + viewWidth); + var topEdge = Math.min(contentY - viewHeight / 2, + contentY + contentHeight - viewHeight); + var bottomEdge = Math.max(contentY + contentHeight + viewHeight / 2, + contentY + viewHeight); + } else { + var leftEdge = blockBox.x; + var rightEdge = leftEdge + blockBox.width; + var topEdge = blockBox.y; + var bottomEdge = topEdge + blockBox.height; + } + var absoluteLeft = 0; + if (this.toolbox_ && this.toolboxPosition == Blockly.TOOLBOX_AT_LEFT) { + absoluteLeft = this.toolbox_.getWidth(); + } + var absoluteTop = 0; + if (this.toolbox_ && this.toolboxPosition == Blockly.TOOLBOX_AT_TOP) { + absoluteTop = this.toolbox_.getHeight(); + } + + var metrics = { + viewHeight: svgSize.height, + viewWidth: svgSize.width, + contentHeight: bottomEdge - topEdge, + contentWidth: rightEdge - leftEdge, + viewTop: -this.scrollY, + viewLeft: -this.scrollX, + contentTop: topEdge, + contentLeft: leftEdge, + absoluteTop: absoluteTop, + absoluteLeft: absoluteLeft, + toolboxWidth: this.toolbox_ ? this.toolbox_.getWidth() : 0, + toolboxHeight: this.toolbox_ ? this.toolbox_.getHeight() : 0, + flyoutWidth: this.flyout_ ? this.flyout_.getWidth() : 0, + flyoutHeight: this.flyout_ ? this.flyout_.getHeight() : 0, + 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 + */ +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.options.gridPattern) { + this.options.gridPattern.setAttribute('x', x); + this.options.gridPattern.setAttribute('y', y); + if (goog.userAgent.IE) { + // IE doesn't notice that the x/y offsets have changed. Force an update. + this.updateGridPattern_(); + } + } +}; +// Export symbols that would otherwise be renamed by Closure compiler. +Blockly.WorkspaceSvg.prototype['setVisible'] = + Blockly.WorkspaceSvg.prototype.setVisible; |