diff --git a/core/dropdowndiv.js b/core/dropdowndiv.js index 426bdab04..501104d01 100644 --- a/core/dropdowndiv.js +++ b/core/dropdowndiv.js @@ -224,7 +224,6 @@ Blockly.DropDownDiv.showPositionedByBlock = function(field, block, return Blockly.DropDownDiv.show(field, primaryX, primaryY, secondaryX, secondaryY, opt_onHide); }; - /** * Shortcut to show and place the drop-down with positioning determined * by a particular field. The primary position will be below the field, @@ -257,7 +256,6 @@ Blockly.DropDownDiv.showPositionedByField = function(owner, owner, primaryX, primaryY, secondaryX, secondaryY, opt_onHide); }; - /** * Show and place the drop-down. * The drop-down is placed with an absolute "origin point" (x, y) - i.e., @@ -281,11 +279,15 @@ Blockly.DropDownDiv.show = function(owner, primaryX, primaryY, var metrics = Blockly.DropDownDiv.getPositionMetrics(primaryX, primaryY, secondaryX, secondaryY); // Update arrow CSS. - Blockly.DropDownDiv.arrow_.style.transform = 'translate(' + - metrics.arrowX + 'px,' + metrics.arrowY + 'px) rotate(45deg)'; - Blockly.DropDownDiv.arrow_.setAttribute('class', metrics.arrowAtTop ? + if (metrics.arrowVisible) { + Blockly.DropDownDiv.arrow_.style.display = ''; + Blockly.DropDownDiv.arrow_.style.transform = 'translate(' + + metrics.arrowX + 'px,' + metrics.arrowY + 'px) rotate(45deg)'; // rotate(45deg) + Blockly.DropDownDiv.arrow_.setAttribute('class', metrics.arrowAtTop ? 'blocklyDropDownArrow arrowTop' : 'blocklyDropDownArrow arrowBottom'); - Blockly.DropDownDiv.arrow_.style.display = metrics.arrowVisible ? '' : 'none'; + } else { + Blockly.DropDownDiv.arrow_.style.display = 'none'; + } // When we change `translate` multiple times in close succession, // Chrome may choose to wait and apply them all at once. @@ -309,6 +311,8 @@ Blockly.DropDownDiv.show = function(owner, primaryX, primaryY, * @private */ Blockly.DropDownDiv.getBoundsInfo_ = function() { + // TODO (#2744): Account for toolboxes. + var boundPosition = Blockly.DropDownDiv.boundsElement_.getBoundingClientRect(); var boundSize = goog.style.getSize(Blockly.DropDownDiv.boundsElement_); @@ -325,91 +329,177 @@ Blockly.DropDownDiv.getBoundsInfo_ = function() { /** * Helper to position the drop-down and the arrow, maintaining bounds. * See explanation of origin points in Blockly.DropDownDiv.show. - * @param {number} primaryX Desired origin point x, in absolute px - * @param {number} primaryY Desired origin point y, in absolute px - * @param {number} secondaryX Secondary/alternative origin point x, in absolute px - * @param {number} secondaryY Secondary/alternative origin point y, in absolute px - * @return {Object} Various final metrics, including rendered positions for drop-down and arrow. + * @param {number} primaryX Desired origin point x, in absolute px. + * @param {number} primaryY Desired origin point y, in absolute px. + * @param {number} secondaryX Secondary/alternative origin point x, + * in absolute px. + * @param {number} secondaryY Secondary/alternative origin point y, + * in absolute px. + * @return {Object} Various final metrics, including rendered positions + * for drop-down and arrow. */ Blockly.DropDownDiv.getPositionMetrics = function(primaryX, primaryY, secondaryX, secondaryY) { var boundsInfo = Blockly.DropDownDiv.getBoundsInfo_(); - var div = Blockly.DropDownDiv.DIV_; - var divSize = goog.style.getSize(div); + var divSize = goog.style.getSize(Blockly.DropDownDiv.DIV_); - // First decide if we will render at primary or secondary position - // i.e., above or below - // renderX, renderY will eventually be the final rendered position of the box. - var renderX, renderY, renderedSecondary, renderedTertiary; - // Can the div fit inside the bounds if we render below the primary point? - if (primaryY + divSize.height > boundsInfo.bottom) { - // We can't fit below in terms of y. Can we fit above? - if (secondaryY - divSize.height < boundsInfo.top) { - // We also can't fit above, so just render at the top of the screen. - renderX = primaryX; - renderY = 0; - renderedSecondary = false; - renderedTertiary = true; - } else { - // We can fit above, render secondary - renderX = secondaryX; - renderY = secondaryY - divSize.height - Blockly.DropDownDiv.PADDING_Y; - renderedSecondary = true; - } - } else { - // We can fit below, render primary - renderX = primaryX; - renderY = primaryY + Blockly.DropDownDiv.PADDING_Y; - renderedSecondary = false; + // Can we fit in-bounds below the target? + if (primaryY + divSize.height < boundsInfo.bottom) { + return Blockly.DropDownDiv.getPositionBelowMetrics( + primaryX, primaryY, boundsInfo, divSize); + } + // Can we fit in-bounds above the target? + if (secondaryY - divSize.height > boundsInfo.top) { + return Blockly.DropDownDiv.getPositionAboveMetrics( + secondaryX, secondaryY, boundsInfo, divSize); + } + // Can we fit outside the workspace bounds (but inside the window) below? + if (primaryY + divSize.height < document.documentElement.clientHeight) { + return Blockly.DropDownDiv.getPositionBelowMetrics( + primaryX, primaryY, boundsInfo, divSize); + } + // Can we fit outside the workspace bounds (but inside the window) above? + if (secondaryY - divSize.height > document.documentElement.clientTop) { + return Blockly.DropDownDiv.getPositionAboveMetrics( + secondaryX, secondaryY, boundsInfo, divSize); } - var centerX = renderX; - // The dropdown's X position is at the top-left of the dropdown rect, but the - // dropdown should appear centered relative to the desired origin point. - renderX -= divSize.width / 2; - // Fit horizontally in the bounds. - renderX = Blockly.utils.math.clamp( - boundsInfo.left, renderX, boundsInfo.right - divSize.width); + // Last resort, render at top of page. + return Blockly.DropDownDiv.getPositionTopOfPageMetrics( + primaryX, boundsInfo, divSize); +}; - // Calculate the absolute arrow X. The arrow wants to be as close to the - // origin point as possible. The arrow may not be centered in the dropdown div. - var absoluteArrowX = centerX - Blockly.DropDownDiv.ARROW_SIZE / 2; - // Keep in overall bounds - absoluteArrowX = Blockly.utils.math.clamp( - boundsInfo.left, absoluteArrowX, boundsInfo.right); +/** + * Get the metrics for positioning the div below the source. + * @param {number} primaryX Desired origin point x, in absolute px. + * @param {number} primaryY Desired origin point y, in absolute px. + * @param {!Object} boundsInfo An object containing size information about the + * bounding element (bounding box and width/height). + * @param {!Object} divSize An object containing information about the size + * of the DropDownDiv (width & height). + * @return {Object} Various final metrics, including rendered positions + * for drop-down and arrow. + */ +Blockly.DropDownDiv.getPositionBelowMetrics = function( + primaryX, primaryY, boundsInfo, divSize) { - // Convert the arrow position to be relative to the top left corner of the div. - var relativeArrowX = absoluteArrowX - renderX; + var xCoords = Blockly.DropDownDiv.getPositionX( + primaryX, boundsInfo.left, boundsInfo.right, divSize.width); - // Pad the arrow by some pixels, primarily so that it doesn't render on top - // of a rounded border. - relativeArrowX = Blockly.utils.math.clamp( - Blockly.DropDownDiv.ARROW_HORIZONTAL_PADDING, - relativeArrowX, - divSize.width - Blockly.DropDownDiv.ARROW_HORIZONTAL_PADDING - - Blockly.DropDownDiv.ARROW_SIZE); - - var arrowY = (renderedSecondary) ? - divSize.height - Blockly.DropDownDiv.BORDER_SIZE : 0; - arrowY -= (Blockly.DropDownDiv.ARROW_SIZE / 2) + - Blockly.DropDownDiv.BORDER_SIZE; - - var initialY; - if (renderedSecondary) { - initialY = secondaryY - divSize.height; // No padding on Y - } else { - initialY = primaryY; // No padding on Y - } + var arrowY = -(Blockly.DropDownDiv.ARROW_SIZE / 2 + + Blockly.DropDownDiv.BORDER_SIZE); + var finalY = primaryY + Blockly.DropDownDiv.PADDING_Y; return { - initialX: renderX, // X position remains constant during animation. - initialY : initialY, - finalX: renderX, - finalY: renderY, - arrowX: relativeArrowX, + initialX: xCoords.divX, + initialY : primaryY, + finalX: xCoords.divX, // X position remains constant during animation. + finalY: finalY, + arrowX: xCoords.arrowX, arrowY: arrowY, - arrowAtTop: !renderedSecondary, - arrowVisible: !renderedTertiary + arrowAtTop: true, + arrowVisible: true + }; +}; + +/** + * Get the metrics for positioning the div above the source. + * @param {number} secondaryX Secondary/alternative origin point x, + * in absolute px. + * @param {number} secondaryY Secondary/alternative origin point y, + * in absolute px. + * @param {!Object} boundsInfo An object containing size information about the + * bounding element (bounding box and width/height). + * @param {!Object} divSize An object containing information about the size + * of the DropDownDiv (width & height). + * @return {Object} Various final metrics, including rendered positions + * for drop-down and arrow. + */ +Blockly.DropDownDiv.getPositionAboveMetrics = function( + secondaryX, secondaryY, boundsInfo, divSize) { + + var xCoords = Blockly.DropDownDiv.getPositionX( + secondaryX, boundsInfo.left, boundsInfo.right, divSize.width); + + var arrowY = divSize.height - (Blockly.DropDownDiv.BORDER_SIZE * 2) + - (Blockly.DropDownDiv.ARROW_SIZE / 2); + var finalY = secondaryY - divSize.height - Blockly.DropDownDiv.PADDING_Y; + var initialY = secondaryY - divSize.height; // No padding on Y + + return { + initialX: xCoords.divX, + initialY : initialY, + finalX: xCoords.divX, // X position remains constant during animation. + finalY: finalY, + arrowX: xCoords.arrowX, + arrowY: arrowY, + arrowAtTop: false, + arrowVisible: true + }; +}; + +/** + * Get the metrics for positioning the div at the top of the page. + * @param {number} sourceX Desired origin point x, in absolute px. + * @param {!Object} boundsInfo An object containing size information about the + * bounding element (bounding box and width/height). + * @param {!Object} divSize An object containing information about the size + * of the DropDownDiv (width & height). + * @return {Object} Various final metrics, including rendered positions + * for drop-down and arrow. + */ +Blockly.DropDownDiv.getPositionTopOfPageMetrics = function( + sourceX, boundsInfo, divSize) { + + var xCoords = Blockly.DropDownDiv.getPositionX( + sourceX, boundsInfo.left, boundsInfo.right, divSize.width); + + // No need to provide arrow-specific information because it won't be visible. + return { + initialX: xCoords.divX, + initialY : 0, + finalX: xCoords.divX, // X position remains constant during animation. + finalY: 0, // Y position remains constant during animation. + arrowVisible: false + }; +}; + +/** + * Get the x positions for the left side of the DropDownDiv and the arrow, + * accounting for the bounds of the workspace. + * @param {number} sourceX Desired origin point x, in absolute px. + * @param {number} boundsLeft The left edge of the bounding element, in + * absolute px. + * @param {number} boundsRight The right edge of the bounding element, in + * absolute px. + * @param {number} divWidth The width of the div in px. + * @return {{divX: number, arrowX: number}} An object containing metrics for + * the x positions of the left side of the DropDownDiv and the arrow. + */ +Blockly.DropDownDiv.getPositionX = function( + sourceX, boundsLeft, boundsRight, divWidth) { + var arrowX, divX; + arrowX = divX = sourceX; + + // Offset the topLeft coord so that the dropdowndiv is centered. + divX -= divWidth / 2; + // Fit the dropdowndiv within the bounds of the workspace. + divX = Blockly.utils.math.clamp(boundsLeft, divX, boundsRight - divWidth); + + // Offset the arrow coord so that the arrow is centered. + arrowX -= Blockly.DropDownDiv.ARROW_SIZE / 2; + // Convert the arrow position to be relative to the top left of the div. + var relativeArrowX = arrowX - divX; + var horizPadding = Blockly.DropDownDiv.ARROW_HORIZONTAL_PADDING; + // Clamp the arrow position so that it stays attached to the dropdowndiv. + relativeArrowX = Blockly.utils.math.clamp( + horizPadding, + relativeArrowX, + divWidth - horizPadding - Blockly.DropDownDiv.ARROW_SIZE); + + return { + arrowX: relativeArrowX, + divX: divX }; }; @@ -481,7 +571,6 @@ Blockly.DropDownDiv.hideWithoutAnimation = function() { } }; - /** * Set the dropdown div's position. * @param {number} initialX Initial Horizontal location diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js new file mode 100644 index 000000000..c7f3a083a --- /dev/null +++ b/tests/mocha/dropdowndiv_test.js @@ -0,0 +1,91 @@ +/** + * @license + * Visual Blocks Editor + * + * Copyright 2019 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. + */ + +suite('DropDownDiv', function() { + suite('Positioning', function() { + setup(function() { + this.boundsStub = sinon.stub(Blockly.DropDownDiv, 'getBoundsInfo_') + .returns({ + left: 0, + right: 100, + top: 0, + bottom: 100, + width: 100, + height: 100 + }); + this.sizeStub = sinon.stub(goog.style, 'getSize') + .returns({ + width: 60, + height: 60 + }); + this.clientHeightStub = sinon.stub(document.documentElement, 'clientHeight') + .get(function() { return 1000; }); + this.clientTopStub = sinon.stub(document.documentElement, 'clientTop') + .get(function() { return 0; }); + }); + teardown(function() { + this.boundsStub.restore(); + this.sizeStub.restore(); + this.clientHeightStub.restore(); + this.clientTopStub.restore(); + }); + test('Below, in Bounds', function() { + var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 0, 50, -10); + // "Above" in value actually means below in render. + chai.assert.isAtLeast(metrics.initialY, 0); + chai.assert.isAbove(metrics.finalY, 0); + chai.assert.isTrue(metrics.arrowVisible); + chai.assert.isTrue(metrics.arrowAtTop); + }); + test('Above, in Bounds', function() { + var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 100, 50, 90); + // "Below" in value actually means above in render. + chai.assert.isAtMost(metrics.initialY, 100); + chai.assert.isBelow(metrics.finalY, 100); + chai.assert.isTrue(metrics.arrowVisible); + chai.assert.isFalse(metrics.arrowAtTop); + }); + test('Below, out of Bounds', function() { + var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 60, 50, 50); + // "Above" in value actually means below in render. + chai.assert.isAtLeast(metrics.initialY, 60); + chai.assert.isAbove(metrics.finalY, 60); + chai.assert.isTrue(metrics.arrowVisible); + chai.assert.isTrue(metrics.arrowAtTop); + }); + test('Above, in Bounds', function() { + var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 100, 50, 90); + // "Below" in value actually means above in render. + chai.assert.isAtMost(metrics.initialY, 100); + chai.assert.isBelow(metrics.finalY, 100); + chai.assert.isTrue(metrics.arrowVisible); + chai.assert.isFalse(metrics.arrowAtTop); + }); + test('No Solution, Render At Top', function() { + this.clientHeightStub.get(function() { return 100; }); + var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 60, 50, 50); + // "Above" in value actually means below in render. + chai.assert.equal(metrics.initialY, 0); + chai.assert.equal(metrics.finalY, 0); + chai.assert.isFalse(metrics.arrowVisible); + chai.assert.isNotOk(metrics.arrowAtTop); + }); + }); +}); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index e338cd5b1..9cbe5c1f6 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -28,6 +28,7 @@ +