/** * @license * Visual Blocks Editor * * Copyright 2018 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 UI bubble. * @author kchadha@scratch.mit.edu (Karishma Chadha) */ 'use strict'; goog.provide('Blockly.ScratchBubble'); goog.require('Blockly.Touch'); goog.require('Blockly.Workspace'); goog.require('goog.dom'); goog.require('goog.math'); goog.require('goog.math.Coordinate'); goog.require('goog.userAgent'); /** * Class for Scratch comment UI bubble. * @param {!Blockly.ScratchBlockComment} comment The comment this bubble belongs * to. * @param {!Blockly.WorkspaceSvg} workspace The workspace on which to draw the * bubble. * @param {!Element} content SVG content for the bubble. * @param {!goog.math.Coordinate} anchorXY Absolute position of bubble's anchor * point. * @param {?number} bubbleWidth Width of bubble, or null if not resizable. * @param {?number} bubbleHeight Height of bubble, or null if not resizable. * @param {?number} bubbleX X position of bubble * @param {?number} bubbleY Y position of bubble * @param {?boolean} minimized Whether or not this comment bubble is minimized * (only the top bar displays), defaults to false if not provided. * @extends {Blockly.Bubble} * @constructor */ Blockly.ScratchBubble = function(comment, workspace, content, anchorXY, bubbleWidth, bubbleHeight, bubbleX, bubbleY, minimized) { // Needed for Events /** * The comment this bubble belongs to. * @type {Blockly.ScratchBlockComment} * @package */ this.comment = comment; this.workspace_ = workspace; this.content_ = content; this.x = bubbleX; this.y = bubbleY; this.isMinimized_ = minimized || false; var canvas = workspace.getBubbleCanvas(); canvas.appendChild(this.createDom_(content, !!(bubbleWidth && bubbleHeight), this.isMinimized_)); this.setAnchorLocation(anchorXY); if (!bubbleWidth || !bubbleHeight) { var bBox = /** @type {SVGLocatable} */ (this.content_).getBBox(); bubbleWidth = bBox.width + 2 * Blockly.ScratchBubble.BORDER_WIDTH; bubbleHeight = bBox.height + 2 * Blockly.ScratchBubble.BORDER_WIDTH; } this.setBubbleSize(bubbleWidth, bubbleHeight); // Render the bubble. this.positionBubble_(); this.renderArrow_(); this.rendered_ = true; if (!workspace.options.readOnly) { Blockly.bindEventWithChecks_( this.minimizeArrow_, 'mousedown', this, this.minimizeArrowMouseDown_, true); Blockly.bindEventWithChecks_( this.minimizeArrow_, 'mouseout', this, this.minimizeArrowMouseOut_, true); Blockly.bindEventWithChecks_( this.minimizeArrow_, 'mouseup', this, this.minimizeArrowMouseUp_, true); Blockly.bindEventWithChecks_( this.deleteIcon_, 'mousedown', this, this.deleteMouseDown_, true); Blockly.bindEventWithChecks_( this.deleteIcon_, 'mouseout', this, this.deleteMouseOut_, true); Blockly.bindEventWithChecks_( this.deleteIcon_, 'mouseup', this, this.deleteMouseUp_, true); Blockly.bindEventWithChecks_( this.commentTopBar_, 'mousedown', this, this.bubbleMouseDown_); Blockly.bindEventWithChecks_( this.bubbleBack_, 'mousedown', this, this.bubbleMouseDown_); if (this.resizeGroup_) { Blockly.bindEventWithChecks_( this.resizeGroup_, 'mousedown', this, this.resizeMouseDown_); Blockly.bindEventWithChecks_( this.resizeGroup_, 'mouseup', this, this.resizeMouseUp_); } } this.setAutoLayout(false); this.moveTo(this.x, this.y); }; goog.inherits(Blockly.ScratchBubble, Blockly.Bubble); /** * Width of the border around the bubble. * @package */ Blockly.ScratchBubble.BORDER_WIDTH = 1; /** * Thickness of the line connecting the bubble * to the block. * @private */ Blockly.ScratchBubble.LINE_THICKNESS = 1; /** * The height of the comment top bar. * @package */ Blockly.ScratchBubble.TOP_BAR_HEIGHT = 32; /** * The size of the minimize arrow icon in the comment top bar. * @private */ Blockly.ScratchBubble.MINIMIZE_ICON_SIZE = 32; /** * The size of the delete icon in the comment top bar. * @private */ Blockly.ScratchBubble.DELETE_ICON_SIZE = 32; /** * The inset for the top bar icons. * @private */ Blockly.ScratchBubble.TOP_BAR_ICON_INSET = 0; /** * The inset for the top bar icons. * @private */ Blockly.ScratchBubble.RESIZE_SIZE = 16; /** * The bottom corner padding of the resize handle touch target. * Extends slightly outside the comment box. * @private */ Blockly.ScratchBubble.RESIZE_CORNER_PAD = 4; /** * The top/side padding around resize handle touch target. * Extends about one extra "diagonal" above resize handle. * @private */ Blockly.ScratchBubble.RESIZE_OUTER_PAD = 8; /** * Create the bubble's DOM. * @param {!Element} content SVG content for the bubble. * @param {boolean} hasResize Add diagonal resize gripper if true. * @param {boolean} minimized Whether the bubble is minimized * @return {!Element} The bubble's SVG group. * @private */ Blockly.ScratchBubble.prototype.createDom_ = function(content, hasResize, minimized) { this.bubbleGroup_ = Blockly.utils.createSvgElement('g', {}, null); this.bubbleArrow_ = Blockly.utils.createSvgElement('line', {'stroke-linecap': 'round'}, this.bubbleGroup_); this.bubbleBack_ = Blockly.utils.createSvgElement('rect', { 'class': 'blocklyDraggable scratchCommentRect', 'x': 0, 'y': 0, 'rx': 4 * Blockly.ScratchBubble.BORDER_WIDTH, 'ry': 4 * Blockly.ScratchBubble.BORDER_WIDTH }, this.bubbleGroup_); this.labelText_ = content.labelText; this.createCommentTopBar_(); // Comment Text Editor this.commentEditor_ = content.commentEditor; this.bubbleGroup_.appendChild(this.commentEditor_); // Comment Resize Handle if (hasResize) { this.createResizeHandle_(); } else { this.resizeGroup_ = null; } // Show / hide relevant things based on minimized state if (minimized) { this.minimizeArrow_.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', Blockly.mainWorkspace.options.pathToMedia + 'comment-arrow-up.svg'); this.commentEditor_.setAttribute('display', 'none'); this.resizeGroup_.setAttribute('display', 'none'); } else { this.minimizeArrow_.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', Blockly.mainWorkspace.options.pathToMedia + 'comment-arrow-down.svg'); this.topBarLabel_.setAttribute('display', 'none'); } return this.bubbleGroup_; }; /** * Create the comment top bar and its contents. * @private */ Blockly.ScratchBubble.prototype.createCommentTopBar_ = function() { this.commentTopBar_ = Blockly.utils.createSvgElement('rect', { 'class': 'blocklyDraggable scratchCommentTopBar', 'rx': Blockly.ScratchBubble.BORDER_WIDTH, 'ry': Blockly.ScratchBubble.BORDER_WIDTH, 'height': Blockly.ScratchBubble.TOP_BAR_HEIGHT }, this.bubbleGroup_); this.createTopBarIcons_(); this.createTopBarLabel_(); }; /** * Create the minimize toggle and delete icons that in the comment top bar. * @private */ Blockly.ScratchBubble.prototype.createTopBarIcons_ = function() { var topBarMiddleY = (Blockly.ScratchBubble.TOP_BAR_HEIGHT / 2) + Blockly.ScratchBubble.BORDER_WIDTH; // Minimize Toggle Icon in Comment Top Bar var xInset = Blockly.ScratchBubble.TOP_BAR_ICON_INSET; this.minimizeArrow_ = Blockly.utils.createSvgElement('image', { 'x': xInset, 'y': topBarMiddleY - Blockly.ScratchBubble.MINIMIZE_ICON_SIZE / 2, 'width': Blockly.ScratchBubble.MINIMIZE_ICON_SIZE, 'height': Blockly.ScratchBubble.MINIMIZE_ICON_SIZE }, this.bubbleGroup_); // Delete Icon in Comment Top Bar this.deleteIcon_ = Blockly.utils.createSvgElement('image', { 'x': xInset, 'y': topBarMiddleY - Blockly.ScratchBubble.DELETE_ICON_SIZE / 2, 'width': Blockly.ScratchBubble.DELETE_ICON_SIZE, 'height': Blockly.ScratchBubble.DELETE_ICON_SIZE }, this.bubbleGroup_); this.deleteIcon_.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', Blockly.mainWorkspace.options.pathToMedia + 'delete-x.svg'); }; /** * Create the comment top bar label. This is the truncated comment text * that shows when comment is minimized. * @private */ Blockly.ScratchBubble.prototype.createTopBarLabel_ = function() { this.topBarLabel_ = Blockly.utils.createSvgElement('text', { 'class': 'scratchCommentText', 'x': this.width_ / 2, 'y': (Blockly.ScratchBubble.TOP_BAR_HEIGHT / 2) + Blockly.ScratchBubble.BORDER_WIDTH, 'text-anchor': 'middle', 'dominant-baseline': 'middle' }, this.bubbleGroup_); var labelTextNode = document.createTextNode(this.labelText_); this.topBarLabel_.appendChild(labelTextNode); }; /** * Create the comment resize handle. * @private */ Blockly.ScratchBubble.prototype.createResizeHandle_ = function() { this.resizeGroup_ = Blockly.utils.createSvgElement('g', {'class': this.workspace_.RTL ? 'scratchCommentResizeSW' : 'scratchCommentResizeSE'}, this.bubbleGroup_); var resizeSize = Blockly.ScratchBubble.RESIZE_SIZE; var outerPad = Blockly.ScratchBubble.RESIZE_OUTER_PAD; var cornerPad = Blockly.ScratchBubble.RESIZE_CORNER_PAD; // Build an (invisible) triangle that will catch resizes. It is padded on the // top/left by outerPad, and padded down/right by cornerPad. Blockly.utils.createSvgElement('polygon', { 'points': [ -outerPad, resizeSize + cornerPad, resizeSize + cornerPad, resizeSize + cornerPad, resizeSize + cornerPad, -outerPad ].join(' ') }, this.resizeGroup_); Blockly.utils.createSvgElement('line', { 'class': 'blocklyResizeLine', 'x1': resizeSize / 3, 'y1': resizeSize - 1, 'x2': resizeSize - 1, 'y2': resizeSize / 3 }, this.resizeGroup_); Blockly.utils.createSvgElement('line', { 'class': 'blocklyResizeLine', 'x1': resizeSize * 2 / 3, 'y1': resizeSize - 1, 'x2': resizeSize - 1, 'y2': resizeSize * 2 / 3 }, this.resizeGroup_); }; /** * Show the context menu for this bubble. * @param {!Event} e Mouse event. * @private */ Blockly.ScratchBubble.prototype.showContextMenu_ = function(e) { if (this.workspace_.options.readOnly) { return; } if (this.contextMenuCallback_) { this.contextMenuCallback_(e); } }; /** * Handle a mouse-down on bubble's minimize icon. * @param {!Event} e Mouse up event. * @private */ Blockly.ScratchBubble.prototype.minimizeArrowMouseDown_ = function(e) { // Set a property indicating that this comment's minimize arrow got a mouse // down event. This property will get reset if the mouse leaves the icon or // when a mouse up occurs on this icon after this mouse down. this.shouldToggleMinimize_ = true; e.stopPropagation(); }; /** * Handle a mouse-out on bubble's minimize icon. * @param {!Event} _e Mouse up event. * @private */ Blockly.ScratchBubble.prototype.minimizeArrowMouseOut_ = function(_e) { // If the mouse has left the minimize arrow icon, the // shouldToggleMinimize property should get reset to false. this.shouldToggleMinimize_ = false; }; /** * Handle a mouse-up on bubble's minimize icon. * @param {!Event} e Mouse up event. * @private */ Blockly.ScratchBubble.prototype.minimizeArrowMouseUp_ = function(e) { // First check if this icon had a mouse down event // on it and that the mouse never left the icon if (this.shouldToggleMinimize_) { this.shouldToggleMinimize_ = false; if (this.minimizeToggleCallback_) { this.minimizeToggleCallback_.call(this); } } e.stopPropagation(); }; /** * Handle a mouse-down on bubble's delete icon. * @param {!Event} e Mouse up event. * @private */ Blockly.ScratchBubble.prototype.deleteMouseDown_ = function(e) { this.shouldDelete_ = true; e.stopPropagation(); }; /** * Handle a mouse-out on bubble's delete icon. * @param {!Event} _e Mouse out event. * @private */ Blockly.ScratchBubble.prototype.deleteMouseOut_ = function(_e) { // If the mouse has left the delete icon, the shouldDelete_ property // should get reset to false. this.shouldDelete_ = false; }; /** * Handle a mouse-up on bubble's delete icon. * @param {!Event} e Mouse up event. * @private */ Blockly.ScratchBubble.prototype.deleteMouseUp_ = function(e) { // First check that this is actually the same icon that had a mouse down event // on it and that the mouse never left the icon if (this.shouldDelete_) { this.shouldDelete_ = false; if (this.deleteCallback_) { this.deleteCallback_.call(this); } } e.stopPropagation(); }; /** * Handle a mouse-down on bubble's resize corner. * @param {!Event} e Mouse down event. * @private */ Blockly.ScratchBubble.prototype.resizeMouseDown_ = function(e) { this.resizeStartSize_ = {width: this.width_, height: this.height_}; this.workspace_.setResizesEnabled(false); Blockly.ScratchBubble.superClass_.resizeMouseDown_.call(this, e); }; /** * Handle a mouse-up on bubble's resize corner. * @param {!Event} _e Mouse up event. * @private */ Blockly.ScratchBubble.prototype.resizeMouseUp_ = function(_e) { var oldHW = this.resizeStartSize_; this.resizeStartSize_ = null; if (this.width_ == oldHW.width && this.height_ == oldHW.height) { return; } // Fire a change event for the new width/height after // resize mouse up Blockly.Events.fire(new Blockly.Events.CommentChange( this.comment, {width: oldHW.width , height: oldHW.height}, {width: this.width_, height: this.height_})); this.workspace_.setResizesEnabled(true); }; /** * Set the minimized state of the bubble. * @param {boolean} minimize Whether the bubble should be minimized * @param {?string} labelText Optional label text for the comment top bar * when it is minimized. * @package */ Blockly.ScratchBubble.prototype.setMinimized = function(minimize, labelText) { if (minimize == this.isMinimized_) { return; } if (minimize) { this.isMinimized_ = true; // Change minimize icon this.minimizeArrow_.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', Blockly.mainWorkspace.options.pathToMedia + 'comment-arrow-up.svg'); // Hide text area this.commentEditor_.setAttribute('display', 'none'); // Hide resize handle if it exists if (this.resizeGroup_) { this.resizeGroup_.setAttribute('display', 'none'); } if (labelText && this.labelText_ != labelText) { // Update label and display this.topBarLabel_.textContent = labelText; } Blockly.utils.removeAttribute(this.topBarLabel_, 'display'); } else { this.isMinimized_ = false; // Change minimize icon this.minimizeArrow_.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', Blockly.mainWorkspace.options.pathToMedia + 'comment-arrow-down.svg'); // Hide label this.topBarLabel_.setAttribute('display', 'none'); // Show text area Blockly.utils.removeAttribute(this.commentEditor_, 'display'); // Display resize handle if it exists if (this.resizeGroup_) { Blockly.utils.removeAttribute(this.resizeGroup_, 'display'); } } }; /** * Register a function as a callback event for when the bubble is minimized. * @param {!Function} callback The function to call on resize. * @package */ Blockly.ScratchBubble.prototype.registerMinimizeToggleEvent = function(callback) { this.minimizeToggleCallback_ = callback; }; /** * Register a function as a callback event for when the bubble is resized. * @param {!Function} callback The function to call on resize. * @package */ Blockly.ScratchBubble.prototype.registerDeleteEvent = function(callback) { this.deleteCallback_ = callback; }; /** * Register a function as a callback to show the context menu for this comment. * @param {!Function} callback The function to call on resize. * @package */ Blockly.ScratchBubble.prototype.registerContextMenuCallback = function(callback) { this.contextMenuCallback_ = callback; }; /** * Notification that the anchor has moved. * Update the arrow and bubble accordingly. * @param {!goog.math.Coordinate} xy Absolute location. * @package */ Blockly.ScratchBubble.prototype.setAnchorLocation = function(xy) { this.anchorXY_ = xy; if (this.rendered_) { this.positionBubble_(); } }; /** * Move the bubble group to the specified location in workspace coordinates. * @param {number} x The x position to move to. * @param {number} y The y position to move to. * @package */ Blockly.ScratchBubble.prototype.moveTo = function(x, y) { Blockly.ScratchBubble.superClass_.moveTo.call(this, x, y); this.updatePosition_(x, y); }; /** * Size this bubble. * @param {number} width Width of the bubble. * @param {number} height Height of the bubble. * @package */ Blockly.ScratchBubble.prototype.setBubbleSize = function(width, height) { var doubleBorderWidth = 2 * Blockly.ScratchBubble.BORDER_WIDTH; // Minimum size of a bubble. width = Math.max(width, doubleBorderWidth + 50); height = Math.max(height, Blockly.ScratchBubble.TOP_BAR_HEIGHT); this.width_ = width; this.height_ = height; this.bubbleBack_.setAttribute('width', width); this.bubbleBack_.setAttribute('height', height); this.commentTopBar_.setAttribute('width', width); this.commentTopBar_.setAttribute('height', Blockly.ScratchBubble.TOP_BAR_HEIGHT); if (this.workspace_.RTL) { this.minimizeArrow_.setAttribute('x', width - (Blockly.ScratchBubble.MINIMIZE_ICON_SIZE) - Blockly.ScratchBubble.TOP_BAR_ICON_INSET); } else { this.deleteIcon_.setAttribute('x', width - Blockly.ScratchBubble.DELETE_ICON_SIZE - Blockly.ScratchBubble.TOP_BAR_ICON_INSET); } if (this.resizeGroup_) { var resizeSize = Blockly.ScratchBubble.RESIZE_SIZE; if (this.workspace_.RTL) { // Mirror the resize group. this.resizeGroup_.setAttribute('transform', 'translate(' + (resizeSize + doubleBorderWidth) + ',' + (this.height_ - doubleBorderWidth - resizeSize) + ') scale(-1, 1)'); } else { this.resizeGroup_.setAttribute('transform', 'translate(' + (this.width_ - doubleBorderWidth - resizeSize) + ',' + (this.height_ - doubleBorderWidth - resizeSize) + ')'); } } if (this.isMinimized_) { this.topBarLabel_.setAttribute('x', this.width_ / 2); this.topBarLabel_.setAttribute('y', this.height_ / 2); } if (this.rendered_) { this.positionBubble_(); this.renderArrow_(); } // Allow the contents to resize. if (this.resizeCallback_) { this.resizeCallback_(); } }; /** * Draw the line between the bubble and the origin. * @private */ Blockly.ScratchBubble.prototype.renderArrow_ = function() { // Find the relative coordinates of the top bar center of the bubble. var relBubbleX = this.width_ / 2; var relBubbleY = Blockly.ScratchBubble.TOP_BAR_HEIGHT / 2; // Find the relative coordinates of the center of the anchor. var relAnchorX = -this.relativeLeft_; var relAnchorY = -this.relativeTop_; if (relBubbleX != relAnchorX || relBubbleY != relAnchorY) { // Compute the angle of the arrow's line. var rise = relAnchorY - relBubbleY; var run = relAnchorX - relBubbleX; if (this.workspace_.RTL) { run *= -1; run -= this.width_; } var baseX1 = relBubbleX; var baseY1 = relBubbleY; this.bubbleArrow_.setAttribute('x1', baseX1); this.bubbleArrow_.setAttribute('y1', baseY1); this.bubbleArrow_.setAttribute('x2', baseX1 + run); this.bubbleArrow_.setAttribute('y2', baseY1 + rise); this.bubbleArrow_.setAttribute('stroke-width', Blockly.ScratchBubble.LINE_THICKNESS); } }; /** * Change the colour of a bubble. * @param {string} hexColour Hex code of colour. * @package */ Blockly.ScratchBubble.prototype.setColour = function(hexColour) { this.bubbleBack_.setAttribute('stroke', hexColour); this.bubbleArrow_.setAttribute('stroke', hexColour); }; /** * Move this bubble during a drag, taking into account whether or not there is * a drag surface. * @param {?Blockly.BlockDragSurfaceSvg} dragSurface The surface that carries * rendered items during a drag, or null if no drag surface is in use. * @param {!goog.math.Coordinate} newLoc The location to translate to, in * workspace coordinates. * @package */ Blockly.ScratchBubble.prototype.moveDuringDrag = function(dragSurface, newLoc) { if (dragSurface) { dragSurface.translateSurface(newLoc.x, newLoc.y); this.updatePosition_(newLoc.x, newLoc.y); } else { this.moveTo(newLoc.x, newLoc.y); } }; /** * Update the relative left and top of the bubble after a move. * @param {number} x The x position of the bubble * @param {number} y The y position of the bubble * @private */ Blockly.ScratchBubble.prototype.updatePosition_ = function(x, y) { // Relative left is the distance *and* direction to get from the comment // anchor position on the block to the starting edge of the comment (e.g. // the left edge of the comment in LTR and the right edge of the comment in RTL) if (this.workspace_.RTL) { // we want relativeLeft_ to actually be the distance from the anchor point to the *right* edge of the comment in RTL this.relativeLeft_ = this.anchorXY_.x - x; } else { this.relativeLeft_ = x - this.anchorXY_.x; } this.relativeTop_ = y - this.anchorXY_.y; this.renderArrow_(); }; /** * Dispose of this bubble. * @package */ Blockly.ScratchBubble.prototype.dispose = function() { Blockly.ScratchBubble.superClass_.dispose.call(this); this.topBarLabel_ = null; this.commentTopBar_ = null; this.minimizeArrow_ = null; this.deleteIcon_ = null; };