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 @@
+