diff --git a/core/blockly.js b/core/blockly.js index 366e55fdf..f17be4d61 100644 --- a/core/blockly.js +++ b/core/blockly.js @@ -326,8 +326,14 @@ Blockly.onContextMenu_ = function(e) { Blockly.hideChaff = function(opt_allowToolbox) { Blockly.Tooltip.hide(); Blockly.WidgetDiv.hide(); + // For now the trashcan flyout always autocloses because it overlays the + // trashcan UI (no trashcan to click to close it) + var workspace = Blockly.getMainWorkspace(); + if (workspace.trashcan && + workspace.trashcan.flyout_) { + workspace.trashcan.flyout_.hide(); + } if (!opt_allowToolbox) { - var workspace = Blockly.getMainWorkspace(); if (workspace.toolbox_ && workspace.toolbox_.flyout_ && workspace.toolbox_.flyout_.autoClose) { diff --git a/core/inject.js b/core/inject.js index df7b48179..2792f54e1 100644 --- a/core/inject.js +++ b/core/inject.js @@ -218,6 +218,12 @@ Blockly.createMainWorkspace_ = function(svg, options, blockDragSurface, var flyout = mainWorkspace.addFlyout_('svg'); Blockly.utils.insertAfter(flyout, svg); } + if (options.hasTrashcan) { + mainWorkspace.addTrashcan(); + } + if (options.zoomOptions && options.zoomOptions.controls) { + mainWorkspace.addZoomControls(); + } // A null translation will also apply the correct initial scale. mainWorkspace.translate(0, 0); @@ -339,6 +345,14 @@ Blockly.init_ = function(mainWorkspace) { } } + var bottom = Blockly.Scrollbar.scrollbarThickness; + if (options.hasTrashcan) { + bottom = mainWorkspace.trashcan.init(bottom); + } + if (options.zoomOptions && options.zoomOptions.controls) { + mainWorkspace.zoomControls_.init(bottom); + } + if (options.hasScrollbars) { mainWorkspace.scrollbar = new Blockly.ScrollbarPair(mainWorkspace); mainWorkspace.scrollbar.resize(); diff --git a/core/trashcan.js b/core/trashcan.js index bd6a70954..8c2c47f70 100644 --- a/core/trashcan.js +++ b/core/trashcan.js @@ -38,6 +38,19 @@ goog.require('goog.math.Rect'); */ Blockly.Trashcan = function(workspace) { this.workspace_ = workspace; + var flyoutWorkspaceOptions = { + scrollbars: true, + disabledPatternId: this.workspace_.options.disabledPatternId, + parentWorkspace: this.workspace_, + RTL: this.workspace_.RTL, + oneBasedIndex: workspace.options.oneBasedIndex, + // TODO: Add horizontal. + /*horizontalLayout: this.workspace_.horizontalLayout,*/ + toolboxPosition: this.workspace_.RTL ? Blockly.TOOLBOX_AT_LEFT : + Blockly.TOOLBOX_AT_RIGHT + }; + this.flyout_ = new Blockly.VerticalFlyout(flyoutWorkspaceOptions); + this.workspace_.addChangeListener(this.onDelete_()); }; /** @@ -96,12 +109,50 @@ Blockly.Trashcan.prototype.SPRITE_LEFT_ = 0; */ Blockly.Trashcan.prototype.SPRITE_TOP_ = 32; +/** + * The openness of the lid when the trashcan contains blocks. + * (0.0 = closed, 1.0 = open) + * @type {number} + * @private + */ +Blockly.Trashcan.prototype.HAS_BLOCKS_LID_ANGLE = 0.1; + +/** + * The maximum number of blocks that can go in the trashcan's flyout. '0' turns + * turns off trashcan contents, 'Infinity' sets it to unlimited. + * @type {number} + * @private + */ +Blockly.Trashcan.prototype.MAX_CONTENTS = 32; + /** * Current open/close state of the lid. * @type {boolean} */ Blockly.Trashcan.prototype.isOpen = false; +/** + * The minimum openness of the lid. Used to indicate if the trashcan contains + * blocks. + * @type {number} + * @private + */ +Blockly.Trashcan.prototype.minOpenness_ = 0; + +/** + * True if the trashcan contains blocks, otherwise false. + * @type {boolean} + * @private + */ +Blockly.Trashcan.prototype.hasBlocks_ = false; + +/** + * A list of Xml (stored as strings) representing blocks "inside" the trashcan. + * @type {Array} + * @private + */ +Blockly.Trashcan.prototype.contents_ = []; + /** * The SVG group containing the trash can. * @type {Element} @@ -207,6 +258,11 @@ Blockly.Trashcan.prototype.createDom = function() { this.workspace_.options.pathToMedia + Blockly.SPRITE.url); Blockly.bindEventWithChecks_(this.svgGroup_, 'mouseup', this, this.click); + // bindEventWithChecks_ quashes events too aggressively. See: + // https://groups.google.com/forum/#!topic/blockly/QF4yB9Wx00s + // Bind to body instead of this.svgGroup_ so that we don't get lid jitters + Blockly.bindEvent_(body, 'mouseover', this, this.mouseOver_); + Blockly.bindEvent_(body, 'mouseout', this, this.mouseOut_); this.animateLid_(); return this.svgGroup_; }; @@ -217,6 +273,10 @@ Blockly.Trashcan.prototype.createDom = function() { * @return {number} Distance from workspace bottom to the top of trashcan. */ Blockly.Trashcan.prototype.init = function(bottom) { + Blockly.utils.insertAfter(this.flyout_.createDom('svg'), + this.workspace_.getParentSvg()); + this.flyout_.init(this.workspace_); + this.bottom_ = this.MARGIN_BOTTOM_ + bottom; this.setOpen_(false); return this.bottom_ + this.BODY_HEIGHT_ + this.LID_HEIGHT_; @@ -240,6 +300,10 @@ Blockly.Trashcan.prototype.dispose = function() { * Move the trash can to the bottom-right corner. */ Blockly.Trashcan.prototype.position = function() { + // Not yet initialized. + if (!this.bottom_) { + return; + } var metrics = this.workspace_.getMetrics(); if (!metrics) { // There are no metrics available (workspace is probably not visible). @@ -309,20 +373,28 @@ Blockly.Trashcan.prototype.setOpen_ = function(state) { */ Blockly.Trashcan.prototype.animateLid_ = function() { this.lidOpen_ += this.isOpen ? 0.2 : -0.2; - this.lidOpen_ = Math.min(Math.max(this.lidOpen_, 0), 1); - var lidAngle = this.lidOpen_ * 45; - this.svgLid_.setAttribute('transform', 'rotate(' + - (this.workspace_.RTL ? -lidAngle : lidAngle) + ',' + - (this.workspace_.RTL ? 4 : this.WIDTH_ - 4) + ',' + - (this.LID_HEIGHT_ - 2) + ')'); + this.lidOpen_ = Math.min(Math.max(this.lidOpen_, this.minOpenness_), 1); + this.setLidAngle_(this.lidOpen_ * 45); // Linear interpolation between 0.4 and 0.8. var opacity = 0.4 + this.lidOpen_ * (0.8 - 0.4); this.svgGroup_.style.opacity = opacity; - if (this.lidOpen_ > 0 && this.lidOpen_ < 1) { + if (this.lidOpen_ > this.minOpenness_ && this.lidOpen_ < 1) { this.lidTask_ = setTimeout(this.animateLid_.bind(this), 20); } }; +/** + * Set the angle of the trashcan's lid. + * @param {!number} lidAngle The angle at which to set the lid. + * @private + */ +Blockly.Trashcan.prototype.setLidAngle_ = function(lidAngle) { + this.svgLid_.setAttribute('transform', 'rotate(' + + (this.workspace_.RTL ? -lidAngle : lidAngle) + ',' + + (this.workspace_.RTL ? 4 : this.WIDTH_ - 4) + ',' + + (this.LID_HEIGHT_ - 2) + ')'); +}; + /** * Flip the lid shut. * Called externally after a drag. @@ -335,10 +407,107 @@ Blockly.Trashcan.prototype.close = function() { * Inspect the contents of the trash. */ Blockly.Trashcan.prototype.click = function() { - var dx = this.workspace_.startScrollX - this.workspace_.scrollX; - var dy = this.workspace_.startScrollY - this.workspace_.scrollY; - if (Math.sqrt(dx * dx + dy * dy) > Blockly.DRAG_RADIUS) { + if (!this.hasBlocks_) { return; } - console.log('TODO: Inspect trash.'); + + var xml = []; + for (var i = 0, text; text = this.contents_[i]; i++) { + xml[i] = Blockly.Xml.textToDom(text).firstChild; + } + this.flyout_.show(xml); +}; + +/** + * Indicate that the trashcan can be clicked (by opening it) if it has blocks. + * @private + */ +Blockly.Trashcan.prototype.mouseOver_ = function() { + if (!this.hasBlocks_) { + return; + } + this.setOpen_(true); +}; + +/** + * Close the lid of the trashcan if it was open (Vis. it was indicating it had + * blocks). + * @private + */ +Blockly.Trashcan.prototype.mouseOut_ = function() { + // No need to do a .hasBlocks check here because if it doesn't the trashcan + // wont be open in the first place, and setOpen_ won't run. + this.setOpen_(false); +}; + +/** + * Handle a BLOCK_DELETE event. Adds deleted blocks oldXml to the content array. + * @returns {!Function} Function to call when a block is deleted. + * @private + */ +Blockly.Trashcan.prototype.onDelete_ = function() { + var trashcan = this; + return function(event) { + if (!trashcan.MAX_CONTENTS) { + return; + } + if (event.type == Blockly.Events.BLOCK_DELETE) { + var cleanedXML = trashcan.cleanBlockXML_(event.oldXml); + if (trashcan.contents_.indexOf(cleanedXML) != -1) { + return; + } + trashcan.contents_.unshift(cleanedXML); + if (trashcan.contents_.length > trashcan.MAX_CONTENTS) { + trashcan.contents_.splice(trashcan.MAX_CONTENTS, + trashcan.contents_.length - trashcan.MAX_CONTENTS); + } + + trashcan.hasBlocks_ = true; + trashcan.minOpenness_ = trashcan.HAS_BLOCKS_LID_ANGLE; + trashcan.setLidAngle_(trashcan.minOpenness_ * 45); + } + }; +}; + +/** + * Converts xml representing a block into text that can be stored in the + * content array. + * @param {!Element} xml An XML tree defining the block and any + * connected child blocks. + * @returns {!string} Text representing the XML tree, cleaned of all unnecessary + * attributes. + * @private + */ +Blockly.Trashcan.prototype.cleanBlockXML_ = function(xml) { + var xmlBlock = xml.cloneNode(true); + var node = xmlBlock; + while (node) { + // Things like text inside tags are still treated as nodes, but they + // don't have attributes (or the removeAttribute function) so we can + // skip removing attributes from them. + if (node.removeAttribute) { + node.removeAttribute('x'); + node.removeAttribute('y'); + node.removeAttribute('id'); + } + + // Try to go down the tree + var nextNode = node.firstChild || node.nextSibling; + // If we can't go down, try to go back up the tree. + if (!nextNode) { + nextNode = node.parentNode; + while (nextNode) { + // We are valid again! + if (nextNode.nextSibling) { + nextNode = nextNode.nextSibling; + break; + } + // Try going up again. If parentNode is null that means we have + // reached the top, and we will break out of both loops. + nextNode = nextNode.parentNode; + } + } + node = nextNode; + } + return '' + Blockly.Xml.domToText(xmlBlock) + ''; }; diff --git a/core/workspace_svg.js b/core/workspace_svg.js index 7484650e8..0ea797bae 100644 --- a/core/workspace_svg.js +++ b/core/workspace_svg.js @@ -466,13 +466,6 @@ Blockly.WorkspaceSvg.prototype.createDom = function(opt_backgroundClass) { /** @type {SVGElement} */ this.svgBubbleCanvas_ = Blockly.utils.createSvgElement('g', {'class': 'blocklyBubbleCanvas'}, this.svgGroup_); - 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, @@ -582,30 +575,24 @@ Blockly.WorkspaceSvg.prototype.newBlock = function(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 + * @package */ -Blockly.WorkspaceSvg.prototype.addTrashcan_ = function(bottom) { +Blockly.WorkspaceSvg.prototype.addTrashcan = function() { /** @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 + * @package */ -Blockly.WorkspaceSvg.prototype.addZoomControls_ = function(bottom) { +Blockly.WorkspaceSvg.prototype.addZoomControls = function() { /** @type {Blockly.ZoomControls} */ this.zoomControls_ = new Blockly.ZoomControls(this); var svgZoomControls = this.zoomControls_.createDom(); this.svgGroup_.appendChild(svgZoomControls); - return this.zoomControls_.init(bottom); }; /** diff --git a/core/zoom_controls.js b/core/zoom_controls.js index 3c8dd8a52..3ca9d05a1 100644 --- a/core/zoom_controls.js +++ b/core/zoom_controls.js @@ -132,6 +132,10 @@ Blockly.ZoomControls.prototype.dispose = function() { * Move the zoom controls to the bottom-right corner. */ Blockly.ZoomControls.prototype.position = function() { + // Not yet initialized. + if (!this.bottom_) { + return; + } var metrics = this.workspace_.getMetrics(); if (!metrics) { // There are no metrics available (workspace is probably not visible).