From 267deceb8208b383f2d3e748fb6b4dfb3ba410e6 Mon Sep 17 00:00:00 2001 From: Sam El-Husseini Date: Wed, 16 Oct 2019 10:20:40 -0500 Subject: [PATCH] Dropdowndiv positioning (#3212) * Fix positioning of the dropdown div --- core/css.js | 2 +- core/dropdowndiv.js | 249 ++++++++++++++++++-------------- tests/mocha/dropdowndiv_test.js | 10 +- 3 files changed, 147 insertions(+), 114 deletions(-) diff --git a/core/css.js b/core/css.js index ef9f17295..8eca3fe0c 100644 --- a/core/css.js +++ b/core/css.js @@ -169,7 +169,7 @@ Blockly.Css.CONTENT = [ '}', '.blocklyDropDownDiv {', - 'position: fixed;', + 'position: absolute;', 'left: 0;', 'top: 0;', 'z-index: 1000;', diff --git a/core/dropdowndiv.js b/core/dropdowndiv.js index cb403bcbd..721d66af1 100644 --- a/core/dropdowndiv.js +++ b/core/dropdowndiv.js @@ -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(); } diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index 541c262e6..0f7c89bb0 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -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);