package com.project.shared.client.utils;
import java.util.ArrayList;
import java.util.HashMap;
import com.google.common.base.Objects;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.gwt.animation.client.Animation;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.Style;
import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.event.dom.client.MouseEvent;
import com.google.gwt.user.client.Random;
import com.google.gwt.user.client.ui.Image;
import com.project.shared.client.events.SimpleEvent;
import com.project.shared.client.net.ImageLoader;
import com.project.shared.client.utils.StyleUtils.UserSelectionMode;
import com.project.shared.data.KeyValue;
import com.project.shared.data.Point2D;
import com.project.shared.data.Rectangle;
public abstract class ElementUtils
{
private static final String CONTENTEDITABLE = "contenteditable";
/**
* A wrapper for the native {@link Element#getChildNodes()} that returns a java ArrayList.
*/
public static ArrayList<Node> getChildNodes(Element element)
{
return NodeUtils.fromNodeList(element.getChildNodes());
}
public static ArrayList<Element> getChildElements(Element element)
{
ArrayList<Element> elements = new ArrayList<Element>();
for (Node childNode : ElementUtils.getChildNodes(element)) {
if (Node.ELEMENT_NODE != childNode.getNodeType())
{
continue;
}
elements.add(Element.as(childNode));
}
return elements;
}
public static boolean areOverlappingElements(Element element1, Element element2) {
// TODO: fix bugs in Rectangle and use isOverlapping instead of isExternalCircleOverlapping
return ElementUtils.getElementAbsoluteRectangle(element1).isExternalCircleOverlapping(
ElementUtils.getElementAbsoluteRectangle(element2));
}
public static Rectangle getElementAbsoluteRectangle(Element element) {
// remember that css coordinates are from top-left of screen
// and css rotation is clockwise
return new Rectangle(ElementUtils.getAbsoluteLeftWithoutTransforms(element), ElementUtils.getAbsoluteTopWithoutTransforms(element),
ElementUtils.getAbsoluteRightWithoutTransforms(element), ElementUtils.getAbsoluteBottomWithoutTransforms(element),
getRotation(element));
}
public static Rectangle getElementOffsetRectangle(Element element) {
// remember that css coordinates are from top-left of screen
// and css rotation is clockwise
return new Rectangle(element.getOffsetLeft(), element.getOffsetTop(),
element.getOffsetLeft() + element.getOffsetWidth(),
element.getOffsetTop() + element.getOffsetHeight(),
getRotation(element));
}
// TODO: warning, this may keep element objects alive after not being used!
private static HashMap<Element, Double> rotations = new HashMap<Element, Double>();
public static void setRotation(Element element, double degrees) {
ElementUtils.setRotation(element, degrees, 0);
}
public static void setRotation(Element element, double degrees, int animationDuration) {
if (0 == animationDuration) {
cssSetRotation(element, degrees);
}
else {
new RotationAnimation(element, degrees).run(animationDuration);
}
if (0 == degrees) {
rotations.remove(element);
}
rotations.put(element, degrees);
}
public static void setTransformOriginTopLeft(Element element) {
String originValue = "0 0";
setTransformOrigin(element, originValue);
}
private static void setTransformOrigin(Element element, String originValue) {
StyleUtils.setPropertyForAllVendors(element.getStyle(), "transformOrigin", originValue);
}
public static void resetTransformOrigin(Element element) {
setTransformOrigin(element, "");
}
/**
* Get the rotation of an element. Assumes that rotations were all set using {@link #setRotation(Element, double)}
* or {@link #setRotation(Element, double, int)}, and either one of the following holds:
*
* <el>
* <li>The element was rotated and no ancestor of the element was rotated</li>
* <li>The element was not rotated, but a single ancestor was rotated around the same axis as the element's rotation axis</li>
* </el>
*/
public static double getRotation(Element element)
{
Double rotation;
while (true) {
rotation = rotations.get(element);
if (null != rotation) {
break;
}
if (false == element.hasParentElement()) {
return 0;
}
element = element.getParentElement();
}
return rotation;
}
private static class RotationAnimation extends Animation {
private Element element;
private double targetDegrees;
private double startDegrees;
public RotationAnimation(Element element, double degrees)
{
this.element = element;
this.targetDegrees = MathUtils.normalAbsoluteDegrees(degrees);
this.startDegrees = MathUtils.normalAbsoluteDegrees(ElementUtils.getRotation(element));
if (Math.abs(this.startDegrees - degrees) > Math.abs(this.startDegrees - MathUtils.CIRCLE_DEGREES + degrees)) {
this.targetDegrees = MathUtils.CIRCLE_DEGREES - degrees;
}
}
@Override
protected void onUpdate(double progress)
{
double curRotation = ((targetDegrees - startDegrees) * Math.max(0, progress)) + startDegrees;
cssSetRotation(element, curRotation);
}
};
private static void cssSetRotation(Element element, double degrees)
{
StyleUtils.setPropertyForAllVendors(element.getStyle(), "transform", "rotate(" + degrees + "deg)");
}
public static void setTextSelectionEnabled(Element element, boolean isEnabled)
{
StyleUtils.setUserSelectionMode(element.getStyle(), isEnabled ? UserSelectionMode.Text : UserSelectionMode.None);
ElementUtils.setDisabledOnDragHandler(element, false == isEnabled);
ElementUtils.setDisabledOnSelectStartHandler(element, false == isEnabled);
}
public static native final void setDisabledOnDragHandler(Element element, boolean disabled) /*-{
if (disabled) {
element.ondrag = function() {
return false;
};
}
else {
element.ondrag = null;
}
}-*/;
public static native final void setDisabledOnSelectStartHandler(Element element, boolean disabled) /*-{
if (disabled) {
element.onselectstart = function() {
return false;
};
}
else {
element.onselectstart = null;
}
}-*/;
/**
* Calculates the position of the mouse event relative to a given element.
* <strong>Don't use event.getRelativeX/Y!</strong>, because Firefox and IE/Chrome have different results for when the element is rotated.
* @param event
* @param elem
* @return
*/
public static Point2D getRelativePosition(MouseEvent<?> event, Element elem) {
Point2D eventPos = new Point2D(event.getClientX(), event.getClientY());
final Point2D elementAbsolutePosition = ElementUtils.getElementAbsolutePosition(elem);
return eventPos.minus(elementAbsolutePosition);
}
public static Point2D getMousePositionRelativeToElement(final Element that)
{
Point2D mousePos = EventUtils.getCurrentMousePos();
if (null == mousePos) {
// can happen if no event is being processed right now.
return null;
}
final Rectangle elementAbsoluteRectangle = ElementUtils.getElementAbsoluteRectangle(that);
return mousePos.minus(elementAbsoluteRectangle.getCorners().topLeft)
.getRotated(-Math.toRadians(elementAbsoluteRectangle.getRotation()));
}
private static class PositionAnimation extends Animation {
private Point2D pos;
private Point2D oldPos;
private Element element;
public PositionAnimation(Point2D oldPos, Point2D pos, Element element)
{
this.oldPos = oldPos;
this.pos = pos;
this.element = element;
}
@Override
protected void onUpdate(double progress)
{
Point2D curPos = pos.minus(oldPos).mul(Math.max(0, progress)).plus(oldPos);
setElementCSSPosition(element, curPos);
}
};
/**
* TODO: For animation to work without glitches, the element must not have
* margins, because getElementOffsetPosition does not take them into
* account, but setElementPosition does.
*/
public static void setElementCSSPosition(final Element element, final Point2D pos, int animationDuration) {
if (0 == animationDuration)
{
ElementUtils.setElementCSSPosition(element, pos);
return;
}
final Point2D oldPos = getElementOffsetPosition(element);
PositionAnimation anim = new PositionAnimation(oldPos, pos, element);
anim.run(animationDuration);
}
/**
* Sets the element's style's top and left css properties, in PX units, to the given coordinates.
* @param element
* @param pos
*/
public static void setElementCSSPosition(Element element, Point2D pos) {
element.getStyle().setLeft(pos.getX(), Unit.PX);
element.getStyle().setTop(pos.getY(), Unit.PX);
}
public static Point2D getElementCSSPosition(Element element)
{
Style style = element.getStyle();
Integer left = StyleUtils.getLeftPx(style);
Integer top = StyleUtils.getTopPx(style);
if (null == left || null == top) {
return null;
}
return new Point2D(left, top);
}
/**
* The number of pixels that the upper top-left corner of the current element is offset to the top-left within the offsetParent node.
*
* Does not include padding or border of the child element.
*/
public static Point2D getElementOffsetPosition(Element element) {
return new Point2D(element.getOffsetLeft(), element.getOffsetTop());
}
/**
* Returns the element's absolute position <strong>disregarding css transforms</strong> (as if no transforms are applied)
* @param element
*/
public static Point2D getElementAbsolutePosition(Element element) {
return new Point2D(ElementUtils.getAbsoluteLeftWithoutTransforms(element), ElementUtils.getAbsoluteTopWithoutTransforms(element));
}
/**
* An alternative to GWT's own implementation of getAbsoluteLeft/Top which is inconsistent among browsers.
*
* We took GWT's implementation from DOMImpl and added - curr.clientLeft/Top to include the borders in
* the calculation.
*
* TODO: Force GWT to use this function instead of <code>getAbsoluteLeft</code> using deferred binding.
*
* @See <a href="http://code.google.com/p/google-web-toolkit/issues/detail?id=5645">GWT issue 5645</a>
* @param elem
*/
public static native int getAbsoluteLeftWithoutTransforms(Element elem)
/*-{
var left = 0;
var curr = elem;
// This intentionally excludes body which has a null offsetParent.
while (curr.offsetParent) {
left -= curr.scrollLeft - curr.clientLeft;
curr = curr.parentNode;
}
while (elem) {
left += elem.offsetLeft;
elem = elem.offsetParent;
}
if (isNaN(left)) {
return 0;
}
return Math.floor(left);
}-*/;
/**
* See {@link #getAbsoluteLeftWithoutTransforms(Element)}
*/
public static native int getAbsoluteTopWithoutTransforms(Element elem)
/*-{
var top = 0;
var curr = elem;
// This intentionally excludes body which has a null offsetParent.
while (curr.offsetParent) {
top -= curr.scrollTop - curr.clientTop;
curr = curr.parentNode;
}
while (elem) {
top += elem.offsetTop;
elem = elem.offsetParent;
}
if (isNaN(top)) {
return 0;
}
return Math.floor(top);
}-*/;
/**
* See {@link #getAbsoluteLeftWithoutTransforms(Element)}
*/
public static int getAbsoluteBottomWithoutTransforms(Element elem) {
return getAbsoluteTopWithoutTransforms(elem) + elem.getOffsetHeight();
}
/**
* See {@link #getAbsoluteLeftWithoutTransforms(Element)}
*/
public static int getAbsoluteRightWithoutTransforms(Element elem) {
return getAbsoluteLeftWithoutTransforms(elem) + elem.getOffsetWidth();
}
/**
* Note: this size includes padding, scroll bars (and margin?) of the element.
* @See <a href="https://developer.mozilla.org/en/Determining_the_dimensions_of_elements">Mozilla article about dimensions</a>
*/
public static Point2D getElementOffsetSize(Element element) {
return new Point2D(element.getOffsetWidth(), element.getOffsetHeight());
}
/**
* Returns the size including padding but not margin, scrollbars, etc.
*/
public static Point2D getElementClientSize(Element element) {
return new Point2D(element.getClientWidth(), element.getClientHeight());
}
public static void setElementSize(Element element, Point2D size)
{
element.getStyle().setWidth(size.getX(), Unit.PX);
element.getStyle().setHeight(size.getY(), Unit.PX);
}
public static void setElementRectangle(Element element, Rectangle rectangle) {
setElementCSSPosition(element, new Point2D(rectangle.getLeft(), rectangle.getTop()));
setElementSize(element, rectangle.getSize());
}
public static void setBackgroundImage(Element element, Image image, boolean autoSize)
{
if (autoSize)
{
Point2D imageSize = new Point2D(image.getWidth(), image.getHeight());
// getWidth/getHeight return zero if the image size is not known. So don't set it.
if (false == Objects.equal(imageSize, Point2D.zero)) {
ElementUtils.setElementSize(element, imageSize);
}
}
element.getStyle().setBackgroundImage(
StyleUtils.buildBackgroundUrl(image.getUrl()));
}
public static void setBackgroundImageAsync(final Element element,
String imageUrl, String errorImageUrl, final boolean autoSize,
final SimpleEvent.Handler<Void> loadHandler, final SimpleEvent.Handler<Void> errorHandler)
{
ImageLoader imageLoader = new ImageLoader();
imageLoader.addLoadHandler(new SimpleEvent.Handler<KeyValue<Integer,Image>>() {
@Override
public void onFire(KeyValue<Integer, Image> arg) {
ElementUtils.setBackgroundImage(element, arg.getValue(), autoSize);
loadHandler.onFire(null);
};
});
imageLoader.addErrorHandler(new SimpleEvent.Handler<Void>() {
@Override
public void onFire(Void arg) {
errorHandler.onFire(null);
};
});
imageLoader.load(new String[]{imageUrl, errorImageUrl});
}
public static void addClassName(Element element, String className)
{
if (Strings.isNullOrEmpty(className))
{
return;
}
element.addClassName(className);
}
public static void removeClassName(Element element, String className)
{
if (Strings.isNullOrEmpty(className))
{
return;
}
element.removeClassName(className);
}
public static void generateId(String prefix, Element elem)
{
elem.setId(prefix + "_" + String.valueOf(Random.nextInt()));
}
/**
* Merges or removes redundant span elements from the hierarchy rooted at rootElem.
* The merges that are done are:
* <el>
* <li>If two adjacent siblings are spans and have the same style, they are merged.</li>
* <li>If an element has only a single child and it is a span, it is merged with the child.</li>
* <li>If a span has no style properties (it completely inherits parent styles) - replace it with its children (move the children to the parent and remove the span).</li>
* </el>
* <p>TODO: Merge adjacent text nodes.</p>
* @param rootElem
* @return True if the tree was changed; false otherwise.
*/
public static boolean mergeSpans(Element rootElem)
{
boolean anyChangeOccured = false;
boolean hasChanged = false;
do {
// first, merge any adjacent child spans that have identical styles
hasChanged = ElementUtils.mergeSiblingSpans(rootElem);
while (true)
{
// repeat until current element doesn't have a single child to be merged with
if (ElementUtils.mergeUpSingleChildSpan(rootElem))
{
hasChanged = true;
continue;
}
break;
}
// Perform the same on all element children
for (Node childNode : ElementUtils.getChildNodes(rootElem))
{
if (Node.ELEMENT_NODE != childNode.getNodeType())
{
continue;
}
Element childElem = Element.as(childNode);
if (ElementUtils.isSpanElement(childElem) && StyleUtils.hasCompletelyInheritedStyle(childElem)) {
for (Node grandChildNode : Lists.reverse(ElementUtils.getChildNodes(childElem))) {
grandChildNode.removeFromParent();
rootElem.insertAfter(grandChildNode, childElem);
}
rootElem.removeChild(childElem);
hasChanged = true;
}
else {
hasChanged |= ElementUtils.mergeSpans(childElem);
}
}
anyChangeOccured |= hasChanged;
} while (hasChanged);
return anyChangeOccured;
}
/**
* Merges every two adjacent span children of the given <code>element</code> if they have equivalent styles.
* @param element
* @return True if any change was made to the tree; false otherwise.
*/
private static boolean mergeSiblingSpans(Element element)
{
boolean hasChanged = false;
Element previousChild = null;
for (Node childNode : ElementUtils.getChildNodes(element))
{
if (Node.ELEMENT_NODE != childNode.getNodeType())
{
previousChild = null;
continue;
}
Element childElem = Element.as(childNode);
if (false == ElementUtils.isSpanElement(childElem))
{
previousChild = null;
continue;
}
if (null == previousChild)
{
previousChild = childElem;
continue;
}
if (false == StyleUtils.areComputedStylesEquivalent(previousChild, childElem))
{
// can't unify.
previousChild = childElem;
continue;
}
// Unify the spans.
for (Node grandChildNode : ElementUtils.getChildNodes(childElem))
{
grandChildNode.removeFromParent();
previousChild.appendChild(grandChildNode);
}
childElem.removeFromParent();
hasChanged = true;
}
return hasChanged;
}
public static boolean isSpanElement(Element elem)
{
return (null != elem) && elem.getTagName().toLowerCase().equals("span");
}
/**
* Checks if the given element has a single child which is a <span>
* if yes, it moves all the grand children (the children of the <span>)
* to become children of this element, and then removes the empty <span>.
* Also, the style of the span is moved up to the element.
* @param element The element for which to perform the operation, if there's a single span child.
*/
public static boolean mergeUpSingleChildSpan(Element element)
{
if (1 != element.getChildCount()) {
return false;
}
Node childNode = element.getChild(0);
if (Node.ELEMENT_NODE != childNode.getNodeType()) {
return false;
}
Element childElem = Element.as(childNode);
if (false == ElementUtils.isSpanElement(childElem))
{
return false;
}
StyleUtils.copyStyle(childElem, element, false);
StyleUtils.copyStyle(element, childElem, true);
for (Node node : ElementUtils.getChildNodes(childElem))
{
node.removeFromParent();
element.appendChild(node);
}
childElem.removeFromParent();
return true;
}
public static void setContentEditable(Element element, boolean isContentEditable)
{
if (isContentEditable) {
element.removeAttribute(CONTENTEDITABLE);
} else {
element.setAttribute(CONTENTEDITABLE, "true");
}
}
public static Rectangle tryGetPaddingRectangle(Element elem)
{
Style style = StyleUtils.getComputedStyle(elem, null);
Integer left = StyleUtils.fromPXUnitString(style.getPaddingLeft());
Integer top = StyleUtils.fromPXUnitString(style.getPaddingTop());
Integer right = StyleUtils.fromPXUnitString(style.getPaddingRight());
Integer bottom = StyleUtils.fromPXUnitString(style.getPaddingBottom());
if ((null != left) && (null != top) && (null != right) && (null != bottom)) {
return new Rectangle(left, top, right, bottom);
}
return null;
}
}