// Copyright 2012 Google Inc. All Rights Reserved.
//
// 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.
package com.google.collide.client.ui.menu;
import com.google.collide.client.AppContext;
import com.google.collide.client.util.CssUtils;
import com.google.collide.client.util.Elements;
import com.google.collide.client.util.RelativeClientRect;
import com.google.common.base.Preconditions;
import com.google.gwt.user.client.Window;
import elemental.css.CSSStyleDeclaration;
import elemental.css.CSSStyleDeclaration.Unit;
import elemental.html.ClientRect;
import elemental.html.Element;
/**
* A controller which handles positioning an element relative to another element. This controller is
* aware of the screen position and will attempt to keep an element on the screen if it would
* otherwise run off. As an additional wrinkle, if flipping it does not produce a valid screen
* position it will just set the offending dimension to 0 .
*
*/
/*
* TODO: In the case where things don't fit on screen we perform a few extra layouts, this
* could be fixed by offsetting the original rect until we find a position that works then
* performing a second layout to actually move it.
*/
public class PositionController {
/**
* A builder which specifies positioning options for a {@link PositionController}.It defaults to
* using {@link VerticalAlign#TOP}, {@link HorizontalAlign#LEFT}, and {@link Position#OVERLAP}.
*/
public static class PositionerBuilder {
private VerticalAlign verticalAlignment = VerticalAlign.TOP;
private HorizontalAlign horizontalAlignment = HorizontalAlign.LEFT;
private Position position = Position.OVERLAP;
public PositionerBuilder setVerticalAlign(VerticalAlign verticalAlignment) {
this.verticalAlignment = verticalAlignment;
return this;
}
public PositionerBuilder setHorizontalAlign(HorizontalAlign horizontalAlignment) {
this.horizontalAlignment = horizontalAlignment;
return this;
}
public PositionerBuilder setPosition(Position position) {
this.position = position;
return this;
}
/**
* Creates a positioner which positions an element next to the provided anchor using gwt_root as
* the container. This is the preferred method of positioning if the element is not going to
* deal with scrolling in anyway and just needs to be positioned around an element.
*/
public Positioner buildAnchorPositioner(Element anchor) {
return new AnchorPositioner(anchor, verticalAlignment, horizontalAlignment, position);
}
/**
* Creates a positioner which positions an element next to the provided anchor using the anchors
* offsetParent as the container. This is the preferred method of positioning if the parent is a
* scrollable container.
*/
public Positioner buildOffsetFromParentPositioner(Element anchor) {
return new OffsetPositioner(anchor, OffsetPositioner.ANCHOR_OFFSET_PARENT, verticalAlignment,
horizontalAlignment, position);
}
/**
* Creates a positioner which positions an element next to the provided anchor using the
* supplied ancestor as the container. This is the preferred method of positioning if the
* ancestor is a scrollable and anchor is not a direct child.
*/
public Positioner buildOffsetFromAncestorPositioner(Element anchor, Element ancestor) {
return new OffsetPositioner(
anchor, ancestor, verticalAlignment, horizontalAlignment, position);
}
/**
* Creates a positioner which appends elements to the body of the document allowing positioning
* absolutely at a given point in the viewport (typically mouse coordinates).
*/
public Positioner buildMousePositioner() {
return new MousePositioner(verticalAlignment, horizontalAlignment, position);
}
}
/**
* The base functionality of a positioner.
*/
public abstract static class Positioner {
private final VerticalAlign verticalAlignment;
private final HorizontalAlign horizontalAlignment;
private final Position position;
private VerticalAlign curVerticalAlignment;
private HorizontalAlign curHorizontalAlignment;
public Positioner(
VerticalAlign verticalAlignment, HorizontalAlign horizontalAlignment, Position position) {
this.verticalAlignment = verticalAlignment;
this.horizontalAlignment = horizontalAlignment;
this.position = position;
revert();
}
public VerticalAlign getVerticalAlignment() {
return curVerticalAlignment;
}
public HorizontalAlign getHorizontalAlignment() {
return curHorizontalAlignment;
}
public Position getPosition() {
return position;
}
/** Appends an element to the appropriate place in the DOM for this positioner */
abstract void appendElementToContainer(Element element);
/**
* Returns the minimum width that should be used by elements being positioned by this
* positioner. The meaning of this value varies depending on the implementation.
*/
public abstract int getMinimumWidth();
abstract double getTop(ClientRect elementRect, int y);
abstract double getLeft(ClientRect elementRect, int x);
void flip(VerticalAlign verticalAlignment, HorizontalAlign horizontalAlignment) {
curVerticalAlignment = verticalAlignment;
curHorizontalAlignment = horizontalAlignment;
}
void revert() {
curVerticalAlignment = verticalAlignment;
curHorizontalAlignment = horizontalAlignment;
}
}
/**
* A positioner which positions an element next to another element.
*/
public static class AnchorPositioner extends Positioner {
private final Element anchor;
private AnchorPositioner(Element anchor, VerticalAlign verticalAlignment,
HorizontalAlign horizontalAlignment, Position position) {
super(verticalAlignment, horizontalAlignment, position);
this.anchor = anchor;
}
/**
* @return the width of the anchor used by this {@link Positioner}.
*/
@Override
public int getMinimumWidth() {
return (int) anchor.getBoundingClientRect().getWidth();
}
/** Appends the element to the body of the DOM */
@Override
void appendElementToContainer(Element element) {
Element gwt = Elements.getElementById(AppContext.GWT_ROOT);
gwt.appendChild(element);
}
@Override
double getTop(ClientRect elementRect, int offsetY) {
Element gwt = Elements.getElementById(AppContext.GWT_ROOT);
ClientRect anchorRect = RelativeClientRect.relativeToRect(
gwt.getBoundingClientRect(), anchor.getBoundingClientRect());
switch (getVerticalAlignment()) {
case TOP:
return anchorRect.getTop() - elementRect.getHeight() - offsetY;
case MIDDLE:
double anchory = anchorRect.getTop() + anchorRect.getHeight() / 2;
return anchory - elementRect.getHeight() / 2 - offsetY;
case BOTTOM:
return anchorRect.getBottom() + offsetY;
default:
return 0;
}
}
@Override
double getLeft(ClientRect elementRect, int offsetX) {
Element gwt = Elements.getElementById(AppContext.GWT_ROOT);
ClientRect anchorRect = RelativeClientRect.relativeToRect(
gwt.getBoundingClientRect(), anchor.getBoundingClientRect());
switch (getHorizontalAlignment()) {
case LEFT:
if (getPosition() == Position.OVERLAP) {
return anchorRect.getLeft() + offsetX;
} else {
return anchorRect.getLeft() - elementRect.getWidth() - offsetX;
}
case MIDDLE:
double anchorx = anchorRect.getLeft() + anchorRect.getWidth() / 2;
return anchorx - elementRect.getWidth() / 2 - offsetX;
case RIGHT:
if (getPosition() == Position.OVERLAP) {
return anchorRect.getRight() - elementRect.getWidth() - offsetX;
} else {
return anchorRect.getRight() + offsetX;
}
default:
return 0;
}
}
}
public static class OffsetPositioner extends Positioner {
/** Indicates that the offsetParent of the anchor should be used for positioning. */
public static final Element ANCHOR_OFFSET_PARENT = Elements.createDivElement();
private final Element anchor;
private final Element offsetAncestor;
private double anchorOffsetTop = -1;
private double anchorOffsetLeft = -1;
private OffsetPositioner(Element anchor, Element ancestor,
VerticalAlign verticalAlignment,
HorizontalAlign horizontalAlignment, Position position) {
super(verticalAlignment, horizontalAlignment, position);
this.anchor = anchor;
this.offsetAncestor = ancestor;
}
/**
* @return the width of the anchor used by this {@link Positioner}.
*/
@Override
public int getMinimumWidth() {
return (int) anchor.getBoundingClientRect().getWidth();
}
/** Appends the element to the specified ancestor of the anchor. */
@Override
void appendElementToContainer(Element element) {
Element container = getOffsetAnchestorForAnchor();
container.appendChild(element);
}
@Override
double getTop(ClientRect elementRect, int offsetY) {
// This rect is to only be used for width and height since the coordinates are relative to the
// viewport and we are relative to an offsetParent.
ClientRect anchorRect = anchor.getBoundingClientRect();
ensureOffsetCalculated();
switch (getVerticalAlignment()) {
case TOP:
return anchorOffsetTop - elementRect.getHeight() - offsetY;
case MIDDLE:
double anchory = anchorOffsetTop + anchorRect.getHeight() / 2;
return anchory - elementRect.getHeight() / 2 - offsetY;
case BOTTOM:
double anchorBottom = anchorOffsetTop + anchorRect.getHeight();
return anchorBottom + offsetY;
default:
return 0;
}
}
@Override
double getLeft(ClientRect elementRect, int offsetX) {
// This rect is to only be used for width and height since the coordinates are relative to the
// viewport and we are relative to an offsetParent.
ClientRect anchorRect = anchor.getBoundingClientRect();
ensureOffsetCalculated();
switch (getHorizontalAlignment()) {
case LEFT:
if (getPosition() == Position.OVERLAP) {
return anchorOffsetLeft + offsetX;
} else {
return anchorOffsetLeft - elementRect.getWidth() - offsetX;
}
case MIDDLE:
double anchorx = anchorOffsetLeft + anchorRect.getWidth() / 2;
return anchorx - elementRect.getWidth() / 2 - offsetX;
case RIGHT:
double anchorRight = anchorOffsetLeft + anchorRect.getWidth();
if (getPosition() == Position.OVERLAP) {
return anchorRight - elementRect.getWidth() - offsetX;
} else {
return anchorRight + offsetX;
}
default:
return 0;
}
}
private Element getOffsetAnchestorForAnchor() {
Element e = offsetAncestor == ANCHOR_OFFSET_PARENT ? anchor.getOffsetParent()
: offsetAncestor;
return e == null ? Elements.getBody() : e;
}
private void ensureOffsetCalculated() {
if (anchorOffsetTop >= 0 && anchorOffsetLeft >= 0) {
return;
}
Element ancestor = getOffsetAnchestorForAnchor();
anchorOffsetTop = anchorOffsetLeft = 0;
for (Element e = anchor; e != ancestor; e = e.getOffsetParent()) {
Preconditions.checkNotNull(e, "Offset parent specified is not in ancestory chain");
anchorOffsetTop += e.getOffsetTop();
anchorOffsetLeft += e.getOffsetLeft();
}
}
}
/**
* A positioner which positions directly next to a point such as the mouse.
*/
public static class MousePositioner extends Positioner {
private MousePositioner(
VerticalAlign verticalAlignment, HorizontalAlign horizontalAlignment, Position position) {
super(verticalAlignment, horizontalAlignment, position);
}
/**
* @returns 0 since this is being positioned next to the mouse.
*/
@Override
public int getMinimumWidth() {
return 0;
}
/** Appends the element to the document body */
@Override
void appendElementToContainer(Element element) {
Elements.getBody().appendChild(element);
}
@Override
double getTop(ClientRect elementRect, int mouseY) {
double top;
switch (getVerticalAlignment()) {
case TOP:
top = mouseY - elementRect.getHeight();
break;
case MIDDLE:
top = mouseY - elementRect.getHeight() / 2;
break;
case BOTTOM:
default:
top = mouseY;
break;
}
return top;
}
@Override
double getLeft(ClientRect elementRect, int mouseX) {
double left;
switch (getHorizontalAlignment()) {
case LEFT:
left = mouseX;
break;
case MIDDLE:
left = mouseX - elementRect.getWidth() / 2;
break;
case RIGHT:
default:
left = mouseX - elementRect.getWidth();
break;
}
return left;
}
}
public enum VerticalAlign {
/**
* Aligns the bottom of the element to the top of the anchor.
*/
TOP,
/**
* Aligns the top of the element to the bottom of the anchor.
*/
BOTTOM,
/**
* Aligns the middle of the element to the middle of the anchor.
*/
MIDDLE,
}
public enum HorizontalAlign {
/**
* Aligns to the left side of the anchor.
*/
LEFT,
/**
* Aligns to the horizontal middle of the anchor.
*/
MIDDLE,
/**
* Aligns to the right side of the anchor.
*/
RIGHT,
}
/**
* Changes the position of the element. If the element is aligned with the RIGHT or LEFT side of
* the anchor, Position will determine whether or not the element overlaps the anchor.
*/
public enum Position {
OVERLAP, NO_OVERLAP
}
/**
* Used to specify that a value should be ignored by
* {@link #setElementLeftAndTop(double, double)}.
*/
private final static double IGNORE = -1;
private final Element element;
private final Positioner elementPositioner;
public PositionController(Positioner positioner, Element element) {
this.elementPositioner = positioner;
this.element = element;
}
/**
* Updates the element's position to move it to the correct location.
*/
public void updateElementPosition() {
updateElementPosition(0, 0);
}
/**
* Updates the element's position. If this controller is aligning next to an anchor then x and y
* will be offsets, otherwise they will be treated as the absolute x and y position to align to.
*
* <p>
* Note: If used as offsets x and y are relative to the aligned edge i.e. if you are aligned to
* the right then x moves you left vs aligning to the left where x moves you to the right.
* </p>
*/
public void updateElementPosition(int x, int y) {
place(x, y);
// check if we're at a valid place, if not temporarily flip the positioner
// and place again.
VerticalAlign originalVertical = elementPositioner.getVerticalAlignment();
HorizontalAlign originalHorizontal = elementPositioner.getHorizontalAlignment();
if (!checkPositionValidAndMaybeUpdatePositioner()) {
place(x, y);
boolean wasVerticalFlipped = originalVertical != elementPositioner.getVerticalAlignment();
boolean wasHorizontalFlipped = originalHorizontal
!= elementPositioner.getHorizontalAlignment();
// Check if the new position is valid,
if (!checkPositionValidAndMaybeUpdatePositioner()) {
/*
* We try to make our best move here, if the element is off the screen in both dimensions
* then the window is tiny and we try to move it to 0,0. if it's only one dimensions we move
* it to either the top or left of the screen.
*/
if (wasVerticalFlipped) {
setElementLeftAndTop(IGNORE, 0);
}
if (wasHorizontalFlipped) {
setElementLeftAndTop(0, IGNORE);
}
}
}
// revert any temporary changes made to our positioner
elementPositioner.revert();
}
public Positioner getPositioner() {
return elementPositioner;
}
/**
* Checks if the element is completely visible on the screen, if not it will temporarily flip our
* {@link #elementPositioner} with updated alignment values which might work to fix the problem.
*/
private boolean checkPositionValidAndMaybeUpdatePositioner() {
// recalculate the element's dimensions and check to see if any of the edges
// of the element are outside the window
ClientRect elementRect = ensureVisibleAndGetRect(element);
VerticalAlign updatedVerticalAlign = elementPositioner.getVerticalAlignment();
HorizontalAlign updatedHorizontalAlign = elementPositioner.getHorizontalAlignment();
if (elementRect.getBottom() > Window.getClientHeight()) {
updatedVerticalAlign = VerticalAlign.TOP;
} else if (elementRect.getTop() < 0) {
updatedVerticalAlign = VerticalAlign.BOTTOM;
}
if (elementRect.getRight() > Window.getClientWidth()) {
updatedHorizontalAlign = HorizontalAlign.RIGHT;
} else if (elementRect.getLeft() < 0) {
updatedHorizontalAlign = HorizontalAlign.LEFT;
}
if (updatedVerticalAlign != elementPositioner.getVerticalAlignment()
|| updatedHorizontalAlign != elementPositioner.getHorizontalAlignment()) {
elementPositioner.flip(updatedVerticalAlign, updatedHorizontalAlign);
return false;
}
return true;
}
/**
* Place the element based on the given information.
*
* @param x the offset or location depending on the underlying positioner.
* @param y the offset or location depending on the underlying positioner.
*/
private void place(int x, int y) {
resetElementPosition();
ClientRect elementRect = ensureVisibleAndGetRect(element);
double left = elementPositioner.getLeft(elementRect, x);
double top = elementPositioner.getTop(elementRect, y);
setElementLeftAndTop(left, top);
}
/**
* Sets an elements left and top to the provided values.
*/
private void setElementLeftAndTop(double left, double top) {
CSSStyleDeclaration style = element.getStyle();
if (left != IGNORE) {
style.setLeft(left, Unit.PX);
}
if (top != IGNORE) {
style.setTop(top, Unit.PX);
}
}
/**
* Resets an element's position by removing top/right/bottom/left and setting position to
* absolute.
*/
private void resetElementPosition() {
CSSStyleDeclaration style = element.getStyle();
style.setPosition("absolute");
style.clearTop();
style.clearRight();
style.clearBottom();
style.clearLeft();
elementPositioner.appendElementToContainer(element);
}
/**
* Ensures that an element is not display: none and is just visibility hidden so we can get an
* accurate client rect.
*/
private static ClientRect ensureVisibleAndGetRect(Element element) {
// Try to get rect and see if it isn't all 0's
ClientRect rect = element.getBoundingClientRect();
double rectSum = rect.getBottom() + rect.getTop() + rect.getLeft() + rect.getRight()
+ rect.getHeight() + rect.getWidth();
if (rectSum != 0) {
return rect;
}
// We make an attempt to get an accurate measurement of the element
CSSStyleDeclaration style = element.getStyle();
String visibility = CssUtils.setAndSaveProperty(element, "visibility", "hidden");
String display = style.getDisplay();
// if display set to none we remove it and let its normal style show through
if (style.getDisplay().equals("none")) {
style.removeProperty("display");
} else {
// it's likely display: none in a css class so we just have to guess.
// We guess display:block since that's common on containers.
style.setDisplay("block");
}
rect = element.getBoundingClientRect();
style.setDisplay(display);
style.setVisibility(visibility);
return rect;
}
}