Dropdowndiv positioning (#3212)

* Fix positioning of the dropdown div
This commit is contained in:
Sam El-Husseini
2019-10-16 10:20:40 -05:00
committed by GitHub
parent 2eaabd180d
commit 267deceb82
3 changed files with 147 additions and 114 deletions

View File

@@ -169,7 +169,7 @@ Blockly.Css.CONTENT = [
'}',
'.blocklyDropDownDiv {',
'position: fixed;',
'position: absolute;',
'left: 0;',
'top: 0;',
'z-index: 1000;',

View File

@@ -34,6 +34,7 @@ goog.require('Blockly.utils.style');
/**
* Class for drop-down div.
* @constructor
* @package
*/
Blockly.DropDownDiv = function() {
};
@@ -68,21 +69,24 @@ Blockly.DropDownDiv.owner_ = null;
Blockly.DropDownDiv.positionToField_ = null;
/**
* Arrow size in px. Should match the value in CSS (need to position pre-render).
* Arrow size in px. Should match the value in CSS
* (need to position pre-render).
* @type {number}
* @const
*/
Blockly.DropDownDiv.ARROW_SIZE = 16;
/**
* Drop-down border size in px. Should match the value in CSS (need to position the arrow).
* Drop-down border size in px. Should match the value in CSS (need to position
* the arrow).
* @type {number}
* @const
*/
Blockly.DropDownDiv.BORDER_SIZE = 1;
/**
* Amount the arrow must be kept away from the edges of the main drop-down div, in px.
* Amount the arrow must be kept away from the edges of the main drop-down div,
* in px.
* @type {number}
* @const
*/
@@ -120,17 +124,20 @@ Blockly.DropDownDiv.DEFAULT_DROPDOWN_COLOR = '#fff';
* Timer for animation out, to be cleared if we need to immediately hide
* without disrupting new shows.
* @type {?number}
* @private
*/
Blockly.DropDownDiv.animateOutTimer_ = null;
/**
* Callback for when the drop-down is hidden.
* @type {?Function}
* @private
*/
Blockly.DropDownDiv.onHide_ = null;
/**
* Create and insert the DOM element for this div.
* @package
*/
Blockly.DropDownDiv.createDom = function() {
if (Blockly.DropDownDiv.DIV_) {
@@ -205,14 +212,6 @@ Blockly.DropDownDiv.setColour = function(backgroundColour, borderColour) {
Blockly.DropDownDiv.DIV_.style.borderColor = borderColour;
};
/**
* Set the category for the drop-down.
* @param {string} category The new category for the drop-down.
*/
Blockly.DropDownDiv.setCategory = function(category) {
Blockly.DropDownDiv.DIV_.setAttribute('data-category', category);
};
/**
* Shortcut to show and place the drop-down with positioning determined
* by a particular block. The primary position will be below the block,
@@ -228,25 +227,9 @@ Blockly.DropDownDiv.setCategory = function(category) {
*/
Blockly.DropDownDiv.showPositionedByBlock = function(field, block,
opt_onHide, opt_secondaryYOffset) {
var scale = block.workspace.scale;
var bBox = {width: block.width, height: block.height};
bBox.width *= scale;
bBox.height *= scale;
var position = block.getSvgRoot().getBoundingClientRect();
// If we can fit it, render below the block.
var primaryX = position.left + bBox.width / 2;
var primaryY = position.top + bBox.height;
// If we can't fit it, render above the entire parent block.
var secondaryX = primaryX;
var secondaryY = position.top;
if (opt_secondaryYOffset) {
secondaryY += opt_secondaryYOffset;
}
// Set bounds to workspace; show the drop-down.
Blockly.DropDownDiv.setBoundsElement(
block.workspace.getParentSvg().parentNode);
return Blockly.DropDownDiv.show(
field, block.RTL, primaryX, primaryY, secondaryX, secondaryY, opt_onHide);
return Blockly.DropDownDiv.showPositionedByRect_(
Blockly.DropDownDiv.getScaledBboxOfBlock_(block),
field, opt_onHide, opt_secondaryYOffset);
};
/**
@@ -263,19 +246,67 @@ Blockly.DropDownDiv.showPositionedByBlock = function(field, block,
*/
Blockly.DropDownDiv.showPositionedByField = function(field,
opt_onHide, opt_secondaryYOffset) {
var position = field.getSvgRoot().getBoundingClientRect();
Blockly.DropDownDiv.positionToField_ = true;
return Blockly.DropDownDiv.showPositionedByRect_(
Blockly.DropDownDiv.getScaledBboxOfField_(field),
field, opt_onHide, opt_secondaryYOffset);
};
/**
* Get the scaled bounding box of a block.
* @param {!Blockly.Block} block The block.
* @return {!Blockly.utils.Rect} The scaled bounding box of the block.
* @private
*/
Blockly.DropDownDiv.getScaledBboxOfBlock_ = function(block) {
var bBox = block.getSvgRoot().getBBox();
var scale = block.workspace.scale;
var scaledHeight = bBox.height * scale;
var scaledWidth = bBox.width * scale;
var xy = Blockly.utils.style.getPageOffset(block.getSvgRoot());
return new Blockly.utils.Rect(
xy.y, xy.y + scaledHeight, xy.x, xy.x + scaledWidth);
};
/**
* Get the scaled bounding box of a field.
* @param {!Blockly.Field} field The field.
* @return {!Blockly.utils.Rect} The scaled bounding box of the field.
* @private
*/
Blockly.DropDownDiv.getScaledBboxOfField_ = function(field) {
var bBox = field.getScaledBBox_();
return new Blockly.utils.Rect(
bBox.top, bBox.bottom, bBox.left, bBox.right);
};
/**
* Helper method to show and place the drop-down with positioning determined
* by a scaled bounding box. The primary position will be below the rect,
* and the secondary position above the rect. Drop-down will be constrained to
* the block's workspace.
* @param {!Blockly.utils.Rect} bBox The scaled bounding box.
* @param {!Blockly.Field} field The field to position the dropdown against.
* @param {Function=} opt_onHide Optional callback for when the drop-down is
* hidden.
* @param {number=} opt_secondaryYOffset Optional Y offset for above-block
* positioning.
* @return {boolean} True if the menu rendered below block; false if above.
* @private
*/
Blockly.DropDownDiv.showPositionedByRect_ = function(bBox, field,
opt_onHide, opt_secondaryYOffset) {
// If we can fit it, render below the block.
var primaryX = position.left + position.width / 2;
var primaryY = position.bottom;
var primaryX = bBox.left + (bBox.right - bBox.left) / 2;
var primaryY = bBox.bottom;
// If we can't fit it, render above the entire parent block.
var secondaryX = primaryX;
var secondaryY = position.top;
var secondaryY = bBox.top;
if (opt_secondaryYOffset) {
secondaryY += opt_secondaryYOffset;
}
var sourceBlock = field.getSourceBlock();
// Set bounds to workspace; show the drop-down.
Blockly.DropDownDiv.positionToField_ = true;
Blockly.DropDownDiv.setBoundsElement(
sourceBlock.workspace.getParentSvg().parentNode);
return Blockly.DropDownDiv.show(
@@ -286,18 +317,21 @@ Blockly.DropDownDiv.showPositionedByField = function(field,
/**
* Show and place the drop-down.
* The drop-down is placed with an absolute "origin point" (x, y) - i.e.,
* the arrow will point at this origin and box will positioned below or above it.
* If we can maintain the container bounds at the primary point, the arrow will
* point there, and the container will be positioned below it.
* If we can't maintain the container bounds at the primary point, fall-back to the
* secondary point and position above.
* the arrow will point at this origin and box will positioned below or above
* it. If we can maintain the container bounds at the primary point, the arrow
* will point there, and the container will be positioned below it.
* If we can't maintain the container bounds at the primary point, fall-back to
* the secondary point and position above.
* @param {Object} owner The object showing the drop-down
* @param {boolean} rtl Right-to-left (true) or left-to-right (false).
* @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
* @param {Function=} opt_onHide Optional callback for when the drop-down is hidden
* @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.
* @param {Function=} opt_onHide Optional callback for when the drop-down is
* hidden.
* @return {boolean} True if the menu rendered at the primary origin point.
* @package
*/
@@ -305,19 +339,6 @@ Blockly.DropDownDiv.show = function(owner, rtl, primaryX, primaryY,
secondaryX, secondaryY, opt_onHide) {
Blockly.DropDownDiv.owner_ = owner;
Blockly.DropDownDiv.onHide_ = opt_onHide || null;
var metrics = Blockly.DropDownDiv.getPositionMetrics(primaryX, primaryY,
secondaryX, secondaryY);
// Update arrow CSS.
if (metrics.arrowVisible) {
Blockly.DropDownDiv.arrow_.style.display = '';
Blockly.DropDownDiv.arrow_.style.transform = 'translate(' +
metrics.arrowX + 'px,' + metrics.arrowY + 'px) rotate(45deg)';
Blockly.DropDownDiv.arrow_.setAttribute('class', metrics.arrowAtTop ?
'blocklyDropDownArrow arrowTop' : 'blocklyDropDownArrow arrowBottom');
} else {
Blockly.DropDownDiv.arrow_.style.display = 'none';
}
// Set direction.
Blockly.DropDownDiv.DIV_.style.direction = rtl ? 'rtl' : 'ltr';
@@ -330,10 +351,8 @@ Blockly.DropDownDiv.show = function(owner, rtl, primaryX, primaryY,
// Using both `left`, `top` for the initial translation and then `translate`
// for the animated transition to final X, Y is a workaround.
Blockly.DropDownDiv.positionInternal_(
metrics.initialX, metrics.initialY,
metrics.finalX, metrics.finalY);
return metrics.arrowAtTop;
return Blockly.DropDownDiv.positionInternal_(
primaryX, primaryY, secondaryX, secondaryY);
};
/**
@@ -343,8 +362,10 @@ Blockly.DropDownDiv.show = function(owner, rtl, primaryX, primaryY,
* @private
*/
Blockly.DropDownDiv.getBoundsInfo_ = function() {
var boundPosition = Blockly.DropDownDiv.boundsElement_.getBoundingClientRect();
var boundSize = Blockly.utils.style.getSize(Blockly.DropDownDiv.boundsElement_);
var boundPosition = Blockly.DropDownDiv.boundsElement_
.getBoundingClientRect();
var boundSize = Blockly.utils.style.getSize(
Blockly.DropDownDiv.boundsElement_);
return {
left: boundPosition.left,
@@ -367,8 +388,9 @@ Blockly.DropDownDiv.getBoundsInfo_ = function() {
* in absolute px.
* @return {Object} Various final metrics, including rendered positions
* for drop-down and arrow.
* @private
*/
Blockly.DropDownDiv.getPositionMetrics = function(primaryX, primaryY,
Blockly.DropDownDiv.getPositionMetrics_ = function(primaryX, primaryY,
secondaryX, secondaryY) {
var boundsInfo = Blockly.DropDownDiv.getBoundsInfo_();
var divSize = Blockly.utils.style.getSize(
@@ -376,27 +398,27 @@ Blockly.DropDownDiv.getPositionMetrics = function(primaryX, primaryY,
// Can we fit in-bounds below the target?
if (primaryY + divSize.height < boundsInfo.bottom) {
return Blockly.DropDownDiv.getPositionBelowMetrics(
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(
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(
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(
return Blockly.DropDownDiv.getPositionAboveMetrics_(
secondaryX, secondaryY, boundsInfo, divSize);
}
// Last resort, render at top of page.
return Blockly.DropDownDiv.getPositionTopOfPageMetrics(
return Blockly.DropDownDiv.getPositionTopOfPageMetrics_(
primaryX, boundsInfo, divSize);
};
@@ -410,8 +432,9 @@ Blockly.DropDownDiv.getPositionMetrics = function(primaryX, primaryY,
* of the DropDownDiv (width & height).
* @return {Object} Various final metrics, including rendered positions
* for drop-down and arrow.
* @private
*/
Blockly.DropDownDiv.getPositionBelowMetrics = function(
Blockly.DropDownDiv.getPositionBelowMetrics_ = function(
primaryX, primaryY, boundsInfo, divSize) {
var xCoords = Blockly.DropDownDiv.getPositionX(
@@ -445,8 +468,9 @@ Blockly.DropDownDiv.getPositionBelowMetrics = function(
* of the DropDownDiv (width & height).
* @return {Object} Various final metrics, including rendered positions
* for drop-down and arrow.
* @private
*/
Blockly.DropDownDiv.getPositionAboveMetrics = function(
Blockly.DropDownDiv.getPositionAboveMetrics_ = function(
secondaryX, secondaryY, boundsInfo, divSize) {
var xCoords = Blockly.DropDownDiv.getPositionX(
@@ -478,8 +502,9 @@ Blockly.DropDownDiv.getPositionAboveMetrics = function(
* of the DropDownDiv (width & height).
* @return {Object} Various final metrics, including rendered positions
* for drop-down and arrow.
* @private
*/
Blockly.DropDownDiv.getPositionTopOfPageMetrics = function(
Blockly.DropDownDiv.getPositionTopOfPageMetrics_ = function(
sourceX, boundsInfo, divSize) {
var xCoords = Blockly.DropDownDiv.getPositionX(
@@ -506,6 +531,7 @@ Blockly.DropDownDiv.getPositionTopOfPageMetrics = function(
* @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.
* @package
*/
Blockly.DropDownDiv.getPositionX = function(
sourceX, boundsLeft, boundsRight, divWidth) {
@@ -613,21 +639,35 @@ Blockly.DropDownDiv.hideWithoutAnimation = function() {
/**
* Set the dropdown div's position.
* @param {number} initialX Initial Horizontal location
* (window coordinates, not body).
* @param {number} initialY Initial Vertical location
* (window coordinates, not body).
* @param {number} finalX Final Horizontal location
* (window coordinates, not body).
* @param {number} finalY Final Vertical location
* (window coordinates, not body).
* @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 {boolean} True if the menu rendered at the primary origin point.
* @private
*/
Blockly.DropDownDiv.positionInternal_ = function(initialX, initialY, finalX, finalY) {
initialX = Math.floor(initialX);
initialY = Math.floor(initialY);
finalX = Math.floor(finalX);
finalY = Math.floor(finalY);
Blockly.DropDownDiv.positionInternal_ = function(
primaryX, primaryY, secondaryX, secondaryY) {
var metrics = Blockly.DropDownDiv.getPositionMetrics_(primaryX, primaryY,
secondaryX, secondaryY);
// Update arrow CSS.
if (metrics.arrowVisible) {
Blockly.DropDownDiv.arrow_.style.display = '';
Blockly.DropDownDiv.arrow_.style.transform = 'translate(' +
metrics.arrowX + 'px,' + metrics.arrowY + 'px) rotate(45deg)';
Blockly.DropDownDiv.arrow_.setAttribute('class', metrics.arrowAtTop ?
'blocklyDropDownArrow arrowTop' : 'blocklyDropDownArrow arrowBottom');
} else {
Blockly.DropDownDiv.arrow_.style.display = 'none';
}
var initialX = Math.floor(metrics.initialX);
var initialY = Math.floor(metrics.initialY);
var finalX = Math.floor(metrics.finalX);
var finalY = Math.floor(metrics.finalY);
var div = Blockly.DropDownDiv.DIV_;
// First apply initial translation.
@@ -643,42 +683,35 @@ Blockly.DropDownDiv.positionInternal_ = function(initialX, initialY, finalX, fin
var dx = finalX - initialX;
var dy = finalY - initialY;
div.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
return metrics.arrowAtTop;
};
/**
* Repositions the dropdownDiv on window resize. If it doesn't know how to
* calculate the new position, it will just hide it instead.
* Repositions the dropdownDiv on window resize. If it doesn't know how to
* calculate the new position, it will just hide it instead.
* @package
*/
Blockly.DropDownDiv.repositionForWindowResize = function() {
// This condition mainly catches the dropdown div when it is being used as a
// dropdown. It is important not to close it in this case because on Android,
// when a field is focused, the soft keyboard opens triggering a window resize
// event and we want the dropdown div to stick around so users can type into it.
// event and we want the dropdown div to stick around so users can type into
// it.
if (Blockly.DropDownDiv.owner_) {
var field = Blockly.DropDownDiv.owner_;
var block = Blockly.DropDownDiv.owner_.getSourceBlock();
var scale = block.workspace.scale;
var bBox = {
width: Blockly.DropDownDiv.positionToField_ ?
Blockly.DropDownDiv.owner_.size_.width : block.width,
height: Blockly.DropDownDiv.positionToField_ ?
Blockly.DropDownDiv.owner_.size_.height : block.height
};
bBox.width *= scale;
bBox.height *= scale;
var position = Blockly.DropDownDiv.positionToField_ ?
Blockly.DropDownDiv.owner_.fieldGroup_.getBoundingClientRect() :
block.getSvgRoot().getBoundingClientRect();
var bBox = Blockly.DropDownDiv.positionToField_ ?
Blockly.DropDownDiv.getScaledBboxOfField_(field) :
Blockly.DropDownDiv.getScaledBboxOfBlock_(block);
// If we can fit it, render below the block.
var primaryX = position.left + bBox.width / 2;
var primaryY = position.top + bBox.height;
var primaryX = bBox.left + (bBox.right - bBox.left) / 2;
var primaryY = bBox.bottom;
// If we can't fit it, render above the entire parent block.
var secondaryX = primaryX;
var secondaryY = position.top;
var metrics = Blockly.DropDownDiv.getPositionMetrics(
primaryX, primaryY, secondaryX, secondaryY);
var secondaryY = bBox.top;
Blockly.DropDownDiv.positionInternal_(
metrics.initialX, metrics.initialY,
metrics.finalX, metrics.finalY);
primaryX, primaryY, secondaryX, secondaryY);
} else {
Blockly.DropDownDiv.hide();
}

View File

@@ -44,7 +44,7 @@ suite('DropDownDiv', function() {
this.clientTopStub.restore();
});
test('Below, in Bounds', function() {
var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 0, 50, -10);
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);
@@ -52,7 +52,7 @@ suite('DropDownDiv', function() {
chai.assert.isTrue(metrics.arrowAtTop);
});
test('Above, in Bounds', function() {
var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 100, 50, 90);
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);
@@ -60,7 +60,7 @@ suite('DropDownDiv', function() {
chai.assert.isFalse(metrics.arrowAtTop);
});
test('Below, out of Bounds', function() {
var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 60, 50, 50);
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);
@@ -68,7 +68,7 @@ suite('DropDownDiv', function() {
chai.assert.isTrue(metrics.arrowAtTop);
});
test('Above, in Bounds', function() {
var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 100, 50, 90);
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);
@@ -77,7 +77,7 @@ suite('DropDownDiv', function() {
});
test('No Solution, Render At Top', function() {
this.clientHeightStub.get(function() { return 100; });
var metrics = Blockly.DropDownDiv.getPositionMetrics(50, 60, 50, 50);
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);