diff --git a/blocks/procedures.js b/blocks/procedures.js index c19ec061869..63ce4c4b899 100644 --- a/blocks/procedures.js +++ b/blocks/procedures.js @@ -946,6 +946,12 @@ Blockly.Blocks['procedures_callnoreturn'] = { * @this Blockly.Block */ customContextMenu: function(options) { + if (!this.workspace.isMovable()) { + // If we center on the block and the workspace isn't movable we could + // loose blocks at the edges of the workspace. + return; + } + var option = {enabled: true}; option.text = Blockly.Msg['PROCEDURES_HIGHLIGHT_DEF']; var name = this.getProcedureCall(); diff --git a/core/events.js b/core/events.js index 4dd4158ee7f..39a76807498 100644 --- a/core/events.js +++ b/core/events.js @@ -154,6 +154,21 @@ Blockly.Events.COMMENT_MOVE = 'comment_move'; */ Blockly.Events.FINISHED_LOADING = 'finished_loading'; +/** + * List of events that cause objects to be bumped back into the visible + * portion of the workspace (only used for non-movable workspaces). + * + * Not to be confused with bumping so that disconnected connections to do + * not appear connected. + * @const + */ +Blockly.Events.BUMP_EVENTS = [ + Blockly.Events.BLOCK_CREATE, + Blockly.Events.BLOCK_MOVE, + Blockly.Events.COMMENT_CREATE, + Blockly.Events.COMMENT_MOVE +]; + /** * List of events queued for firing. * @private diff --git a/core/inject.js b/core/inject.js index 2be8fff630a..6a875dbc119 100644 --- a/core/inject.js +++ b/core/inject.js @@ -232,71 +232,112 @@ Blockly.createMainWorkspace_ = function(svg, options, blockDragSurface, mainWorkspace.translate(0, 0); Blockly.mainWorkspace = mainWorkspace; - if (!options.readOnly && !options.hasScrollbars) { - var workspaceChanged = function(e) { - if (!mainWorkspace.isDragging()) { - var metrics = mainWorkspace.getMetrics(); - var edgeLeft = metrics.viewLeft + metrics.absoluteLeft; - var edgeTop = metrics.viewTop + metrics.absoluteTop; - if (metrics.contentTop < edgeTop || - metrics.contentTop + metrics.contentHeight > - metrics.viewHeight + edgeTop || - metrics.contentLeft < - (options.RTL ? metrics.viewLeft : edgeLeft) || - metrics.contentLeft + metrics.contentWidth > (options.RTL ? - metrics.viewWidth : metrics.viewWidth + edgeLeft)) { - // One or more blocks may be out of bounds. Bump them back in. - var MARGIN = 25; - var blocks = mainWorkspace.getTopBlocks(false); + if (!options.readOnly && !mainWorkspace.isMovable()) { + // Helper function for the workspaceChanged callback. + // TODO (#2300): Move metrics math back to the WorkspaceSvg. + var getWorkspaceMetrics = function() { + var workspaceMetrics = Object.create(null); + var defaultMetrics = mainWorkspace.getMetrics(); + var scale = mainWorkspace.scale; + + // Get the view metrics in workspace units. + workspaceMetrics.viewLeft = defaultMetrics.viewLeft / scale; + workspaceMetrics.viewTop = defaultMetrics.viewTop / scale; + workspaceMetrics.viewRight = + (defaultMetrics.viewLeft + defaultMetrics.viewWidth) / scale; + workspaceMetrics.viewBottom = + (defaultMetrics.viewTop + defaultMetrics.viewHeight) / scale; + + // Get the exact content metrics (in workspace units), even if the + // content is bounded. + if (mainWorkspace.isContentBounded()) { + // Already in workspace units, no need to divide by scale. + var blocksBoundingBox = mainWorkspace.getBlocksBoundingBox(); + workspaceMetrics.contentLeft = blocksBoundingBox.x; + workspaceMetrics.contentTop = blocksBoundingBox.y; + workspaceMetrics.contentRight = + blocksBoundingBox.x + blocksBoundingBox.width; + workspaceMetrics.contentBottom = + blocksBoundingBox.y + blocksBoundingBox.height; + } else { + workspaceMetrics.contentLeft = defaultMetrics.contentLeft / scale; + workspaceMetrics.contentTop = defaultMetrics.contentTop / scale; + workspaceMetrics.contentRight = + (defaultMetrics.contentLeft + defaultMetrics.contentWidth) / scale; + workspaceMetrics.contentBottom = + (defaultMetrics.contentTop + defaultMetrics.contentHeight) / scale; + } + + return workspaceMetrics; + }; + + var bumpObjects = function(e) { + // We always check isMovable_ again because the original + // "not movable" state of isMovable_ could have been changed. + if (!mainWorkspace.isDragging() && !mainWorkspace.isMovable() && + (Blockly.Events.BUMP_EVENTS.indexOf(e.type) != -1)) { + var metrics = getWorkspaceMetrics(); + if (metrics.contentTop < metrics.viewTop || + metrics.contentBottom > metrics.viewBottom || + metrics.contentLeft < metrics.viewLeft || + metrics.contentRight > metrics.viewRight) { + + // Handle undo. var oldGroup = null; if (e) { oldGroup = Blockly.Events.getGroup(); Blockly.Events.setGroup(e.group); } - var movedBlocks = false; - for (var b = 0, block; block = blocks[b]; b++) { - var blockXY = block.getRelativeToSurfaceXY(); - var blockHW = block.getHeightWidth(); - // Bump any block that's above the top back inside. - var overflowTop = edgeTop + MARGIN - blockHW.height - blockXY.y; - if (overflowTop > 0) { - block.moveBy(0, overflowTop); - movedBlocks = true; - } - // Bump any block that's below the bottom back inside. - var overflowBottom = - edgeTop + metrics.viewHeight - MARGIN - blockXY.y; - if (overflowBottom < 0) { - block.moveBy(0, overflowBottom); - movedBlocks = true; - } - // Bump any block that's off the left back inside. - var overflowLeft = MARGIN + edgeLeft - - blockXY.x - (options.RTL ? 0 : blockHW.width); - if (overflowLeft > 0) { - block.moveBy(overflowLeft, 0); - movedBlocks = true; - } - // Bump any block that's off the right back inside. - var overflowRight = edgeLeft + metrics.viewWidth - MARGIN - - blockXY.x + (options.RTL ? blockHW.width : 0); - if (overflowRight < 0) { - block.moveBy(overflowRight, 0); - movedBlocks = true; - } + + switch (e.type) { + case Blockly.Events.BLOCK_CREATE: + case Blockly.Events.BLOCK_MOVE: + var object = mainWorkspace.getBlockById(e.blockId); + break; + case Blockly.Events.COMMENT_CREATE: + case Blockly.Events.COMMENT_MOVE: + var object = mainWorkspace.getCommentById(e.commentId); + break; } + var objectMetrics = object.getBoundingRectangle(); + + // Bump any object that's above the top back inside. + var overflowTop = metrics.viewTop - objectMetrics.topLeft.y; + if (overflowTop > 0) { + object.moveBy(0, overflowTop); + } + + // Bump any object that's below the bottom back inside. + var overflowBottom = metrics.viewBottom - objectMetrics.bottomRight.y; + if (overflowBottom < 0) { + object.moveBy(0, overflowBottom); + } + + // Bump any object that's off the left back inside. + var overflowLeft = metrics.viewLeft - objectMetrics.topLeft.x; + if (overflowLeft > 0) { + object.moveBy(overflowLeft, 0); + } + + // Bump any object that's off the right back inside. + var overflowRight = metrics.viewRight - objectMetrics.bottomRight.x; + if (overflowRight < 0) { + object.moveBy(overflowRight, 0); + } + if (e) { - if (!e.group && movedBlocks) { - console.log('WARNING: Moved blocks in bounds but there was no event group.' - + ' This may break undo.'); + if (!e.group) { + console.log('WARNING: Moved object in bounds but there was no' + + ' event group. This may break undo.'); } Blockly.Events.setGroup(oldGroup); } } } }; - mainWorkspace.addChangeListener(workspaceChanged); + mainWorkspace.addChangeListener(bumpObjects); } + // The SVG is now fully assembled. Blockly.svgResize(mainWorkspace); Blockly.WidgetDiv.createDom(); @@ -339,12 +380,6 @@ Blockly.init_ = function(mainWorkspace) { mainWorkspace.flyout_.init(mainWorkspace); mainWorkspace.flyout_.show(options.languageTree.childNodes); mainWorkspace.flyout_.scrollToStart(); - // Translate the workspace sideways to avoid the fixed flyout. - mainWorkspace.scrollX = mainWorkspace.flyout_.width_; - if (options.toolboxPosition == Blockly.TOOLBOX_AT_RIGHT) { - mainWorkspace.scrollX *= -1; - } - mainWorkspace.translate(mainWorkspace.scrollX, 0); } } @@ -356,9 +391,31 @@ Blockly.init_ = function(mainWorkspace) { mainWorkspace.zoomControls_.init(verticalSpacing); } - if (options.hasScrollbars) { + if (options.moveOptions && options.moveOptions.scrollbars) { mainWorkspace.scrollbar = new Blockly.ScrollbarPair(mainWorkspace); mainWorkspace.scrollbar.resize(); + } else { + mainWorkspace.setMetrics({x: .5, y: .5}); + } + + if (mainWorkspace.flyout_) { + // Translate the workspace sideways to avoid the fixed flyout. + switch (mainWorkspace.toolboxPosition) { + case Blockly.TOOLBOX_AT_LEFT: + mainWorkspace.scrollX = + mainWorkspace.RTL ? 0 : mainWorkspace.flyout_.width_; + break; + case Blockly.TOOLBOX_AT_RIGHT: + mainWorkspace.scrollX = + mainWorkspace.RTL ? -mainWorkspace.flyout_.width_ : 0; + break; + case Blockly.TOOLBOX_AT_TOP: + mainWorkspace.scrollY = mainWorkspace.flyout_.height_; + break; + // If the toolbox is at the top left (workspace origin) is untouched, + // so no need to include it. + } + mainWorkspace.translate(mainWorkspace.scrollX, mainWorkspace.scrollY); } // Load the sounds. @@ -380,6 +437,9 @@ Blockly.init_ = function(mainWorkspace) { */ Blockly.inject.bindDocumentEvents_ = function() { if (!Blockly.documentEventsBound_) { + Blockly.bindEventWithChecks_(document, 'scroll', null, + Blockly.mainWorkspace.updateInverseScreenCTM + .bind(Blockly.mainWorkspace)); Blockly.bindEventWithChecks_(document, 'keydown', null, Blockly.onKeyDown_); // longStop needs to run to stop the context menu from showing up. It // should run regardless of what other touch event handlers have run. diff --git a/core/options.js b/core/options.js index d6503d3a714..572178638ac 100644 --- a/core/options.js +++ b/core/options.js @@ -100,10 +100,6 @@ Blockly.Options = function(options) { Blockly.TOOLBOX_AT_RIGHT : Blockly.TOOLBOX_AT_LEFT; } - var hasScrollbars = options['scrollbars']; - if (hasScrollbars === undefined) { - hasScrollbars = hasCategories; - } var hasCss = options['css']; if (hasCss === undefined) { hasCss = true; @@ -135,7 +131,9 @@ Blockly.Options = function(options) { this.maxInstances = options['maxInstances']; this.pathToMedia = pathToMedia; this.hasCategories = hasCategories; - this.hasScrollbars = hasScrollbars; + this.moveOptions = Blockly.Options.parseMoveOptions(options, hasCategories); + /** @deprecated January 2019 */ + this.hasScrollbars = this.moveOptions.scrollbars; this.hasTrashcan = hasTrashcan; this.maxTrashcanContents = maxTrashcanContents; this.hasSounds = hasSounds; @@ -165,6 +163,40 @@ Blockly.Options.prototype.setMetrics = null; */ Blockly.Options.prototype.getMetrics = null; +/** + * Parse the user-specified move options, using reasonable defaults where + * behavior is unspecified. + * @param {!Object} options Dictionary of options. + * @param {!boolean} hasCategories Whether the workspace has categories or not. + * @return {!Object} A dictionary of normalized options. + * @private + */ +Blockly.Options.parseMoveOptions = function(options, hasCategories) { + var move = options['move'] || {}; + var moveOptions = {}; + if (move['scrollbars'] === undefined + && options['scrollbars'] === undefined) { + moveOptions.scrollbars = hasCategories; + } else { + moveOptions.scrollbars = !!move['scrollbars'] || !!options['scrollbars']; + } + if (!moveOptions.scrollbars || move['wheel'] === undefined) { + // Defaults to false so that developers' settings don't appear to change. + moveOptions.wheel = false; + } else { + moveOptions.wheel = !!move['wheel']; + } + if (!moveOptions.scrollbars) { + moveOptions.drag = false; + } else if (move['drag'] === undefined) { + // Defaults to true if scrollbars is true. + moveOptions.drag = true; + } else { + moveOptions.drag = !!move['drag']; + } + return moveOptions; +}; + /** * Parse the user-specified zoom options, using reasonable defaults where * behaviour is unspecified. See zoom documentation: diff --git a/core/workspace_dragger.js b/core/workspace_dragger.js index 1181e9f036b..00c28818738 100644 --- a/core/workspace_dragger.js +++ b/core/workspace_dragger.js @@ -45,15 +45,6 @@ Blockly.WorkspaceDragger = function(workspace) { */ this.workspace_ = workspace; - /** - * The workspace's metrics object at the beginning of the drag. Contains size - * and position metrics of a workspace. - * Coordinate system: pixel coordinates. - * @type {!Object} - * @private - */ - this.startDragMetrics_ = workspace.getMetrics(); - /** * The scroll position of the workspace at the beginning of the drag. * Coordinate system: pixel coordinates. @@ -102,30 +93,6 @@ Blockly.WorkspaceDragger.prototype.endDrag = function(currentDragDeltaXY) { * @package */ Blockly.WorkspaceDragger.prototype.drag = function(currentDragDeltaXY) { - var metrics = this.startDragMetrics_; var newXY = goog.math.Coordinate.sum(this.startScrollXY_, currentDragDeltaXY); - - // Bound the new XY based on workspace bounds. - var x = Math.min(newXY.x, -metrics.contentLeft); - var y = Math.min(newXY.y, -metrics.contentTop); - x = Math.max(x, metrics.viewWidth - metrics.contentLeft - - metrics.contentWidth); - y = Math.max(y, metrics.viewHeight - metrics.contentTop - - metrics.contentHeight); - - x = -x - metrics.contentLeft; - y = -y - metrics.contentTop; - - this.updateScroll_(x, y); -}; - -/** - * Move the scrollbars to drag the workspace. - * x and y are in pixels. - * @param {number} x The new x position to move the scrollbar to. - * @param {number} y The new y position to move the scrollbar to. - * @private - */ -Blockly.WorkspaceDragger.prototype.updateScroll_ = function(x, y) { - this.workspace_.scrollbar.set(x, y); + this.workspace_.scroll(newXY.x, newXY.y); }; diff --git a/core/workspace_svg.js b/core/workspace_svg.js index 0ea797baec9..cd317ad76ce 100644 --- a/core/workspace_svg.js +++ b/core/workspace_svg.js @@ -168,13 +168,69 @@ Blockly.WorkspaceSvg.prototype.isMutator = false; Blockly.WorkspaceSvg.prototype.resizesEnabled_ = true; /** - * Current horizontal scrolling offset in pixel units. + * Current horizontal scrolling offset in pixel units, relative to the + * workspace origin. + * + * It is useful to think about a view, and a canvas moving beneath that + * view. As the canvas moves right, this value becomes more positive, and + * the view is now "seeing" the left side of the canvas. As the canvas moves + * left, this value becomes more negative, and the view is now "seeing" the + * right side of the canvas. + * + * The confusing thing about this value is that it does not, and must not + * include the absoluteLeft offset. This is because it is used to calculate + * the viewLeft value. + * + * The viewLeft is relative to the workspace origin (although in pixel + * units). The workspace origin is the top-left corner of the workspace (at + * least when it is enabled). It is shifted from the top-left of the blocklyDiv + * so as not to be beneath the toolbox. + * + * When the workspace is enabled the viewLeft and workspace origin are at + * the same X location. As the canvas slides towards the right beneath the view + * this value (scrollX) becomes more positive, and the viewLeft becomes more + * negative relative to the workspace origin (imagine the workspace origin + * as a dot on the canvas sliding to the right as the canvas moves). + * + * So if the scrollX were to include the absoluteLeft this would in a way + * "unshift" the workspace origin. This means that the viewLeft would be + * representing the left edge of the blocklyDiv, rather than the left edge + * of the workspace. + * * @type {number} */ Blockly.WorkspaceSvg.prototype.scrollX = 0; /** - * Current vertical scrolling offset in pixel units. + * Current vertical scrolling offset in pixel units, relative to the + * workspace origin. + * + * It is useful to think about a view, and a canvas moving beneath that + * view. As the canvas moves down, this value becomes more positive, and the + * view is now "seeing" the upper part of the canvas. As the canvas moves + * up, this value becomes more negative, and the view is "seeing" the lower + * part of the canvas. + * + * This confusing thing about this value is that it does not, and must not + * include the absoluteTop offset. This is because it is used to calculate + * the viewTop value. + * + * The viewTop is relative to the workspace origin (although in pixel + * units). The workspace origin is the top-left corner of the workspace (at + * least when it is enabled). It is shifted from the top-left of the + * blocklyDiv so as not to be beneath the toolbox. + * + * When the workspace is enabled the viewTop and workspace origin are at the + * same Y location. As the canvas slides towards the bottom this value + * (scrollY) becomes more positive, and the viewTop becomes more negative + * relative to the workspace origin (image in the workspace origin as a dot + * on the canvas sliding downwards as the canvas moves). + * + * So if the scrollY were to include the absoluteTop this would in a way + * "unshift" the workspace origin. This means that the viewTop would be + * representing the top edge of the blocklyDiv, rather than the top edge of + * the workspace. + * * @type {number} */ Blockly.WorkspaceSvg.prototype.scrollY = 0; @@ -470,11 +526,8 @@ Blockly.WorkspaceSvg.prototype.createDom = function(opt_backgroundClass) { if (!this.isFlyout) { Blockly.bindEventWithChecks_(this.svgGroup_, 'mousedown', this, this.onMouseDown_, false, true); - if (this.options.zoomOptions && this.options.zoomOptions.wheel) { - // Mouse-wheel. - Blockly.bindEventWithChecks_(this.svgGroup_, 'wheel', this, - this.onMouseWheel_); - } + Blockly.bindEventWithChecks_(this.svgGroup_, 'wheel', this, + this.onMouseWheel_); } // Determine if there needs to be a category tree, or a simple list of @@ -675,10 +728,9 @@ Blockly.WorkspaceSvg.prototype.resizeContents = function() { 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(); + var metrics = this.getMetrics(); + this.scrollbar.hScroll.resizeContentHorizontal(metrics); + this.scrollbar.vScroll.resizeContentVertical(metrics); } this.updateInverseScreenCTM(); }; @@ -762,8 +814,10 @@ Blockly.WorkspaceSvg.prototype.getParentSvg = function() { /** * Translate this workspace to new coordinates. - * @param {number} x Horizontal translation. - * @param {number} y Vertical translation. + * @param {number} x Horizontal translation, in pixel units relative to the + * top left of the blockly div. + * @param {number} y Vertical translation, in pixel units relative to the + * top left of the blockly div. */ Blockly.WorkspaceSvg.prototype.translate = function(x, y) { if (this.useWorkspaceDragSurface_ && this.isDragSurfaceActive_) { @@ -778,6 +832,10 @@ Blockly.WorkspaceSvg.prototype.translate = function(x, y) { if (this.blockDragSurface_) { this.blockDragSurface_.translateAndScaleGroup(x, y, this.scale); } + // And update the grid if we're using one. + if (this.grid_) { + this.grid_.moveTo(x, y); + } }; /** @@ -1193,11 +1251,44 @@ Blockly.WorkspaceSvg.prototype.isDragging = function() { }; /** - * Is this workspace draggable and scrollable? + * Is this workspace draggable? * @return {boolean} True if this workspace may be dragged. */ Blockly.WorkspaceSvg.prototype.isDraggable = function() { - return !!this.scrollbar; + return this.options.moveOptions && this.options.moveOptions.drag; +}; + +/** + * Should the workspace have bounded content? Used to tell if the + * workspace's content should be sized so that it can move (bounded) or not + * (exact sizing). + * @returns {boolean} True if the workspace should be bounded, false otherwise. + * @package + */ +Blockly.WorkspaceSvg.prototype.isContentBounded = function() { + return (this.options.moveOptions && this.options.moveOptions.scrollbars) + || (this.options.moveOptions && this.options.moveOptions.wheel) + || (this.options.moveOptions && this.options.moveOptions.drag) + || (this.options.zoomOptions && this.options.zoomOptions.controls) + || (this.options.zoomOptions && this.options.zoomOptions.wheel); +}; + +/** + * Is this workspace movable? + * + * This means the user can reposition the X Y coordinates of the workspace + * through input. This can be through scrollbars, scroll wheel, dragging, or + * through zooming with the scroll wheel (since the zoom is centered on the + * mouse position). This does not include zooming with the zoom controls + * since the X Y coordinates are decided programmatically. + * @returns {boolean} True if the workspace is movable, false otherwise. + * @package + */ +Blockly.WorkspaceSvg.prototype.isMovable = function() { + return (this.options.moveOptions && this.options.moveOptions.scrollbars) + || (this.options.moveOptions && this.options.moveOptions.wheel) + || (this.options.moveOptions && this.options.moveOptions.drag) + || (this.options.zoomOptions && this.options.zoomOptions.wheel); }; /** @@ -1206,17 +1297,43 @@ Blockly.WorkspaceSvg.prototype.isDraggable = function() { * @private */ Blockly.WorkspaceSvg.prototype.onMouseWheel_ = function(e) { + var canWheelZoom = this.options.zoomOptions && this.options.zoomOptions.wheel; + var canWheelMove = this.options.moveOptions && this.options.moveOptions.wheel; + if (!canWheelZoom && !canWheelMove) { + return; + } + // TODO: Remove gesture cancellation and compensate for coordinate skew during // zoom. if (this.currentGesture_) { this.currentGesture_.cancel(); } - // 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); + + // TODO (#2301): Change '10' from magic number to constant variable. Also + // change in flyout_vertical.js and flyout_horizontal.js. + // Multiplier variable, so that non-pixel-deltaModes are supported. + var multiplier = e.deltaMode === 0x1 ? 10 : 1; + + if (canWheelZoom && (e.ctrlKey || !canWheelMove)) { + // The vertical scroll distance that corresponds to a click of a zoom button. + var PIXELS_PER_ZOOM_STEP = 50; + var delta = -e.deltaY / PIXELS_PER_ZOOM_STEP * multiplier; + var position = Blockly.utils.mouseToSvg(e, this.getParentSvg(), + this.getInverseScreenCTM()); + this.zoom(position.x, position.y, delta); + } else { + var x = this.scrollX - e.deltaX * multiplier; + var y = this.scrollY - e.deltaY * multiplier; + + if (e.shiftKey && e.deltaX === 0) { + // Scroll horizontally (based on vertical scroll delta) + // This is needed as for some browser/system combinations which do not + // set deltaX. + x = this.scrollX - e.deltaY * multiplier; + y = this.scrollY; // Don't scroll vertically + } + this.scroll(x, y); + } e.preventDefault(); }; @@ -1312,7 +1429,7 @@ Blockly.WorkspaceSvg.prototype.showContextMenu_ = function(e) { menuOptions.push(redoOption); // Option to clean up blocks. - if (this.scrollbar) { + if (this.isMovable()) { var cleanOption = {}; cleanOption.text = Blockly.Msg['CLEAN_UP']; cleanOption.enabled = topBlocks.length > 1; @@ -1509,43 +1626,53 @@ Blockly.WorkspaceSvg.prototype.setBrowserFocus = function() { }; /** - * 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). + * Zooms the workspace in or out relative to/centered on the given (x, y) + * coordinate. + * @param {number} x X coordinate of center, in pixel units relative to the + * top-left corner of the parentSVG. + * @param {number} y Y coordinate of center, in pixel units relative to the + * top-left corner of the parentSVG. + * @param {number} amount Amount of zooming. The formula for the new scale + * is newScale = currentScale * (scaleSpeed^amount). scaleSpeed is set in + * the workspace options. Negative amount values zoom out, and positive + * amount values zoom 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 speed = this.options.zoomOptions.scaleSpeed; var scaleChange = Math.pow(speed, amount); - // Clamp scale within valid range. var newScale = this.scale * scaleChange; + if (this.scale == newScale) { + return; // No change in zoom. + } + + // Clamp scale within valid range. 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; - } + + // Transform the x/y coordinates from the parentSVG's space into the + // canvas' space, so that they are in workspace units relative to the top + // left of the visible portion of the workspace. + var matrix = this.getCanvas().getCTM(); + var center = this.getParentSvg().createSVGPoint(); + center.x = x; + center.y = y; + center = center.matrixTransform(matrix.inverse()); + x = center.x; + y = center.y; + + // Find the new scrollX/scrollY so that the center remains in the same + // position (relative to the center) after we zoom. + var matrix = matrix.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. + var metrics = this.getMetrics(); + this.scrollX = matrix.e - metrics.absoluteLeft; + this.scrollY = matrix.f - metrics.absoluteTop; this.setScale(newScale); }; @@ -1555,8 +1682,8 @@ Blockly.WorkspaceSvg.prototype.zoom = function(x, y, amount) { */ Blockly.WorkspaceSvg.prototype.zoomCenter = function(type) { var metrics = this.getMetrics(); - var x = metrics.viewWidth / 2; - var y = metrics.viewHeight / 2; + var x = (metrics.viewWidth / 2) + metrics.absoluteLeft; + var y = (metrics.viewHeight / 2) + metrics.absoluteTop; this.zoom(x, y, type); }; @@ -1564,6 +1691,12 @@ Blockly.WorkspaceSvg.prototype.zoomCenter = function(type) { * Zoom the blocks to fit in the workspace if possible. */ Blockly.WorkspaceSvg.prototype.zoomToFit = function() { + if (!this.isMovable()) { + console.warn('Tried to move a non-movable workspace. This could result' + + ' in blocks becoming inaccessible.'); + return; + } + var metrics = this.getMetrics(); var blocksBox = this.getBlocksBoundingBox(); var blocksWidth = blocksBox.width; @@ -1571,18 +1704,22 @@ Blockly.WorkspaceSvg.prototype.zoomToFit = function() { 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; + // We add the flyout size to the block size because the flyout contains + // blocks, and we want all of the blocks to fit within the view. If we + // don't add them, they'll end up overlapping. + if (this.horizontalLayout) { + // Convert from pixels to workspace coordinates. + blocksHeight += this.flyout_.height_ / this.scale; + } else { + // Convert from pixels to workspace coordinates. + blocksWidth += this.flyout_.width_ / this.scale; + } } - var ratioX = workspaceWidth / blocksWidth; - var ratioY = workspaceHeight / blocksHeight; + + // Scale Units: (pixels / workspaceUnit) + var ratioX = metrics.viewWidth / blocksWidth; + var ratioY = metrics.viewHeight / blocksHeight; this.setScale(Math.min(ratioX, ratioY)); this.scrollCenter(); }; @@ -1613,22 +1750,42 @@ Blockly.WorkspaceSvg.prototype.endCanvasTransition = function() { /** @type {!SVGElement} */ (this.svgBubbleCanvas_), 'blocklyCanvasTransitioning'); }; + /** * 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.'); + if (!this.isMovable()) { + console.warn('Tried to move a non-movable workspace. This could result' + + ' in blocks becoming inaccessible.'); return; } + var metrics = this.getMetrics(); var x = (metrics.contentWidth - metrics.viewWidth) / 2; + var y = (metrics.contentHeight - metrics.viewHeight) / 2; + // Shift the x and y to disregard the permanent flyout (if it exists). if (this.flyout_) { - x -= this.flyout_.width_ / 2; + switch (this.toolboxPosition) { + case Blockly.TOOLBOX_AT_LEFT: + x -= this.flyout_.width_ / 2; + break; + case Blockly.TOOLBOX_AT_RIGHT: + x += this.flyout_.width_ / 2; + break; + case Blockly.TOOLBOX_AT_TOP: + y -= this.flyout_.height_ / 2; + break; + case Blockly.TOOLBOX_AT_BOTTOM: + y += this.flyout_.height_ / 2; + break; + } } - var y = (metrics.contentHeight - metrics.viewHeight) / 2; - this.scrollbar.set(x, y); + + // Convert from workspace directions to canvas directions. + x = -x - metrics.contentLeft; + y = -y - metrics.contentTop; + this.scroll(x, y); }; /** @@ -1637,8 +1794,9 @@ Blockly.WorkspaceSvg.prototype.scrollCenter = function() { * @public */ Blockly.WorkspaceSvg.prototype.centerOnBlock = function(id) { - if (!this.scrollbar) { - console.warn('Tried to scroll a non-scrollable workspace.'); + if (!this.isMovable()) { + console.warn('Tried to move a non-movable workspace. This could result' + + ' in blocks becoming inaccessible.'); return; } @@ -1682,13 +1840,17 @@ Blockly.WorkspaceSvg.prototype.centerOnBlock = function(id) { var scrollToCenterX = scrollToBlockX - halfViewWidth; var scrollToCenterY = scrollToBlockY - halfViewHeight; + // Convert from workspace directions to canvas directions. + var x = -scrollToCenterX - metrics.contentLeft; + var y = -scrollToCenterY - metrics.contentTop; + Blockly.hideChaff(); - this.scrollbar.set(scrollToCenterX, scrollToCenterY); + this.scroll(x, y); }; /** * Set the workspace's zoom factor. - * @param {number} newScale Zoom factor. + * @param {number} newScale Zoom factor. Units: (pixels / workspaceUnit). */ Blockly.WorkspaceSvg.prototype.setScale = function(newScale) { if (this.options.zoomOptions.maxScale && @@ -1702,10 +1864,14 @@ Blockly.WorkspaceSvg.prototype.setScale = function(newScale) { if (this.grid_) { this.grid_.update(this.scale); } + // We call scroll instead of scrollbar.resize() so that we can center the + // zoom correctly without scrollbars, but scroll does not resize the + // scrollbars so we have to call resizeContent as well. + this.scroll(this.scrollX, this.scrollY); if (this.scrollbar) { - this.scrollbar.resize(); - } else { - this.translate(this.scrollX, this.scrollY); + var metrics = this.getMetrics(); + this.scrollbar.hScroll.resizeContentHorizontal(metrics); + this.scrollbar.vScroll.resizeContentVertical(metrics); } Blockly.hideChaff(false); if (this.flyout_) { @@ -1714,6 +1880,58 @@ Blockly.WorkspaceSvg.prototype.setScale = function(newScale) { } }; +/** + * Scroll the workspace to a specified offset (in pixels), keeping in the + * workspace bounds. See comment on workspaceSvg.scrollX for more detail on + * the meaning of these values. + * @param {number} x Target X to scroll to. + * @param {number} y Target Y to scroll to. + * @package + */ +Blockly.WorkspaceSvg.prototype.scroll = function(x, y) { + // Keep scrolling within the bounds of the content. + var metrics = this.getMetrics(); + // This is the offset of the top-left corner of the view from the + // workspace origin when the view is "seeing" the bottom-right corner of + // the content. + var maxOffsetOfViewFromOriginX = metrics.contentWidth + metrics.contentLeft - + metrics.viewWidth; + var maxOffsetOfViewFromOriginY = metrics.contentHeight + metrics.contentTop - + metrics.viewHeight; + // Canvas coordinates (aka scroll coordinates) have inverse directionality + // to workspace coordinates so we have to inverse them. + x = Math.min(x, -metrics.contentLeft); + y = Math.min(y, -metrics.contentTop); + x = Math.max(x, -maxOffsetOfViewFromOriginX); + y = Math.max(y, -maxOffsetOfViewFromOriginY); + + this.scrollX = x; + this.scrollY = y; + if (this.scrollbar) { + // The content position (displacement from the content's top-left to the + // origin) plus the scroll position (displacement from the view's top-left + // to the origin) gives us the distance from the view's top-left to the + // content's top-left. Then we negate this so we get the displacement from + // the content's top-left to the view's top-left, matching the + // directionality of the scrollbars. + + // TODO (#2299): Change these to not use the internal ratio_ property. + this.scrollbar.hScroll.setHandlePosition(-(x + metrics.contentLeft) * + this.scrollbar.hScroll.ratio_); + this.scrollbar.vScroll.setHandlePosition(-(y + metrics.contentTop) * + this.scrollbar.vScroll.ratio_); + } + + // Hide the WidgetDiv without animation. This is to prevent a disposal + // animation from happening in the wrong location. + Blockly.WidgetDiv.hide(true); + // We have to shift the translation so that when the canvas is at 0, 0 the + // workspace origin is not underneath the toolbox. + x += metrics.absoluteLeft; + y += metrics.absoluteTop; + this.translate(x, y); +}; + /** * Get the dimensions of the given workspace component, in pixels. * @param {Blockly.Toolbox|Blockly.Flyout} elem The element to get the @@ -1750,7 +1968,7 @@ Blockly.WorkspaceSvg.getDimensionsPx_ = function(elem) { * @private */ Blockly.WorkspaceSvg.getContentDimensions_ = function(ws, svgSize) { - if (ws.scrollbar) { + if (ws.isContentBounded()) { return Blockly.WorkspaceSvg.getContentDimensionsBounded_(ws, svgSize); } else { return Blockly.WorkspaceSvg.getContentDimensionsExact_(ws); @@ -1829,22 +2047,27 @@ Blockly.WorkspaceSvg.getContentDimensionsBounded_ = function(ws, svgSize) { /** * 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. + * Coordinate system: pixel coordinates, -left, -up, +right, +down + * .viewHeight: Height of the visible portion of the workspace. + * .viewWidth: Width of the visible portion of the workspace. + * .contentHeight: Height of the content. + * .contentWidth: Width of the content. + * .viewTop: Top-edge of the visible portion of the workspace, relative to + * the workspace origin. + * .viewLeft: Left-edge of the visible portion of the workspace, relative to + * the workspace origin. + * .contentTop: Top-edge of the content, relative to the workspace origin. + * .contentLeft: Left-edge of the content relative to the workspace origin. + * .absoluteTop: Top-edge of the visible portion of the workspace, relative + * to the blocklyDiv. + * .absoluteLeft: Left-edge of the visible portion of the workspace, relative + * to the blocklyDiv. + * .toolboxWidth: Width of the toolbox, if it exists. Otherwise zero. + * .toolboxHeight: Height of the 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. + * .flyoutHeight: Height of the flyout if it is always open. Otherwise zero. + * .toolboxPosition: Top, bottom, left or right. Use TOOLBOX_AT constants to + * compare. * @return {!Object} Contains size and position metrics of a top level * workspace. * @private @@ -1910,17 +2133,13 @@ Blockly.WorkspaceSvg.getTopLevelWorkspaceMetrics_ = function() { }; /** - * Sets the X/Y translations of a top level workspace to match the scrollbars. + * Sets the X/Y translations of a top level workspace. * @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 Error('Attempt to set top level workspace scroll without ' + - 'scrollbars.'); - } var metrics = this.getMetrics(); if (typeof xyRatio.x == 'number') { this.scrollX = -metrics.contentWidth * xyRatio.x - metrics.contentLeft; @@ -1928,12 +2147,12 @@ Blockly.WorkspaceSvg.setTopLevelWorkspaceMetrics_ = function(xyRatio) { if (typeof xyRatio.y == 'number') { this.scrollY = -metrics.contentHeight * xyRatio.y - metrics.contentTop; } + // We have to shift the translation so that when the canvas is at 0, 0 the + // workspace origin is not underneath the toolbox. var x = this.scrollX + metrics.absoluteLeft; var y = this.scrollY + metrics.absoluteTop; + // We could call scroll here, but that has extra checks we don't need to do. this.translate(x, y); - if (this.grid_) { - this.grid_.moveTo(x, y); - } }; /** diff --git a/core/zoom_controls.js b/core/zoom_controls.js index 5103d15cb69..c6b38e44790 100644 --- a/core/zoom_controls.js +++ b/core/zoom_controls.js @@ -102,7 +102,11 @@ Blockly.ZoomControls.prototype.createDom = function() { var rnd = String(Math.random()).substring(2); this.createZoomOutSvg_(rnd); this.createZoomInSvg_(rnd); - this.createZoomResetSvg_(rnd); + if (this.workspace_.isMovable()) { + // If we zoom to the center and the workspace isn't movable we could + // loose blocks at the edges of the workspace. + this.createZoomResetSvg_(rnd); + } return this.svgGroup_; }; @@ -158,7 +162,9 @@ Blockly.ZoomControls.prototype.position = function() { if (metrics.toolboxPosition == Blockly.TOOLBOX_AT_BOTTOM) { this.top_ = this.verticalSpacing_; this.zoomInGroup_.setAttribute('transform', 'translate(0, 34)'); - this.zoomResetGroup_.setAttribute('transform', 'translate(0, 77)'); + if (this.zoomResetGroup_) { + this.zoomResetGroup_.setAttribute('transform', 'translate(0, 77)'); + } } else { this.top_ = metrics.viewHeight + metrics.absoluteTop - this.HEIGHT_ - this.verticalSpacing_; diff --git a/tests/playground.html b/tests/playground.html index bfca40eb4d0..01b34d7bb81 100644 --- a/tests/playground.html +++ b/tests/playground.html @@ -109,7 +109,11 @@ oneBasedIndex: true, readOnly: false, rtl: rtl, - scrollbars: true, + move: { + scrollbars: true, + drag: true, + wheel: false, + }, toolbox: toolbox, toolboxPosition: side == 'top' || side == 'start' ? 'start' : 'end', zoom: