/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.waveprotocol.wave.client.common.util;
import com.google.gwt.core.client.JavaScriptException;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.dom.client.DivElement;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.NodeList;
import com.google.gwt.dom.client.Style.Display;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.ui.impl.FocusImpl;
import org.waveprotocol.wave.model.document.util.Point;
import org.waveprotocol.wave.model.util.CollectionUtils;
import org.waveprotocol.wave.model.util.IdentitySet;
import org.waveprotocol.wave.model.util.Preconditions;
import org.waveprotocol.wave.model.util.ReadableStringSet;
import org.waveprotocol.wave.model.util.StringSet;
/**
* Helper methods
*
* Some adapted from UIElement, so the interface could do with increasing consistency
*
* TODO(danilatos,user): Clean up / organise methods in this class
*
* @author danilatos@google.com (Daniel Danilatos)
*/
public class DomHelper {
/**
* Describes the editability of an element, ignoring its context (ancestor nodes, etc).
*/
public enum ElementEditability {
/** The element is definitely editable */
EDITABLE,
/** The element is not editable */
NOT_EDITABLE,
/** The element is "neutral", which means its editability is inherited */
NEUTRAL
}
/** Webkit editability controlling css property */
public static final String WEBKIT_USER_MODIFY = "-webkit-user-modify";
/**
* Interface for receiving low-level javascript events
*/
public interface JavaScriptEventListener {
/**
* @param name The event name, without any leading "on-" prefix
* @param event The native event object
*/
void onJavaScriptEvent(String name, Event event);
}
private DomHelper() {}
/**
* Return true if the element is a text box
* @param element
* @return true if the element is a text box
*/
public static boolean isTextBox(Element element) {
return "input".equalsIgnoreCase(element.getTagName())
&& "text".equalsIgnoreCase(element.getAttribute("type"));
}
/**
* @param element
* @param styleName
* @return true if the element or an ancestor has the given stylename
*/
public static boolean hasStyleOrAncestorHasStyle(Element element, String styleName) {
while (element != null) {
if (element.getClassName().indexOf(styleName) >= 0) {
return true;
}
element = element.getParentElement();
}
return false;
}
/**
* Cast to old-style Element.
*
* TODO(danilatos): Deprecate this method when GWT has updated everything to not require
* the old style Element.
*
* @param element new style element
* @return old style element
*/
public static com.google.gwt.user.client.Element castToOld(Element element) {
return element.cast();
}
/**
* Create a div with the given style name set. Convenience method because
* this is such a common task
* @param styleName
* @return The created div element
*/
public static DivElement createDivWithStyle(String styleName) {
DivElement d = Document.get().createDivElement();
d.setClassName(styleName);
return d;
}
/**
* Focus the element, if possible
* @param element
*/
public static void focus(Element element) {
// NOTE(user): This may not work for divs, rather use getFocusImplForPanel
// for divs.
try {
FocusImpl.getFocusImplForWidget().focus(castToOld(element));
} catch (Exception e) {
// Suppress null pointer condition
}
}
/**
* Blur the element, if possible
* @param element
*
* NOTE(user): Dan thinks this method should be deprecated, but is not
* sure why... Dan, please update once you remember.
*/
public static void blur(Element element) {
FocusImpl.getFocusImplForWidget().blur(castToOld(element));
}
/**
* Sets display:none on the given element if isVisible is false, and clears
* the display css property if isVisible is true.
*
* This idiom is commonly switched on a boolean, so this method takes care of
* the 5 lines of boilerplate.
*
* @param element
* @param isVisible
*/
public static void setDisplayVisible(Element element, boolean isVisible) {
if (isVisible) {
element.getStyle().clearDisplay();
} else {
element.getStyle().setDisplay(Display.NONE);
}
}
/**
* Finds the index of an element in its parent's list of child elements.
* This is not the same as {@link #findChildIndex(Node)}, since it ignores
* non-element nodes. It is in line with the element-only view of a collection
* of children exposed by {@link Element#getFirstChildElement()} and
* {@link Element#getNextSiblingElement()}.
*
* @param child an element
* @return the index of {@code child}, or -1 if {@code child} is not a child
* of its parent.
* @see #findChildIndex(Node)
*/
public static final int findChildElementIndex(Element child) {
Element parent = child.getParentElement();
Element e = parent.getFirstChildElement();
int i = 0;
while (e != null) {
if (e.equals(child)) {
return i;
} else {
e = e.getNextSiblingElement();
i++;
}
}
return -1;
}
/**
* Wrap at least one node
* @param with The element in which to wrap the nodes
* @param from First node to wrap
* @param toExcl Node after end of wrap range
*/
public static void wrap(Element with, Node from, Node toExcl) {
from.getParentNode().insertBefore(with, from);
moveNodes(with, from, toExcl, null);
}
/**
* @param element The element to unwrap. If not attached, does nothing.
*/
public static void unwrap(Element element) {
if (element.hasParentElement()) {
moveNodes(element.getParentElement(),
element.getFirstChild(), null, element.getNextSibling());
element.removeFromParent();
}
}
/**
* Insert before, but for a range of adjacent siblings
*
* TODO(danilatos): Apparently safari and firefox let you do this in one
* go using ranges, which could be a lot faster than iterating manually.
* Create a deferred binding implementation.
* @param parent
* @param from
* @param toExcl
* @param refChild
*/
public static void moveNodes(Element parent, Node from, Node toExcl, Node refChild) {
for (Node n = from; n != toExcl; ) {
Node m = n;
n = n.getNextSibling();
parent.insertBefore(m, refChild);
}
}
/**
* Remove nodes in the given range from the DOM
* @param from
* @param toExcl
*/
public static void removeNodes(Node from, Node toExcl) {
if (from == null || !from.hasParentElement()) {
return;
}
for (Node n = from; n != toExcl && n != null;) {
Node r = n;
n = n.getNextSibling();
r.removeFromParent();
}
}
/**
* Remove all children from an element
* @param element
*/
public static void emptyElement(Element element) {
while (element.getFirstChild() != null) {
element.removeChild(element.getFirstChild());
}
}
/**
* Ensures the given container contains exactly one child, the given one.
* Provides the important property that if the container is already the parent
* of the given child, then the child is not removed and re-added, it is left
* there; any siblings, if present, are removed.
*
* @param container
* @param child
*/
public static void setOnlyChild(Element container, Node child) {
if (child.getParentElement() != container) {
// simple case
emptyElement(container);
container.appendChild(child);
} else {
// tricky case - avoid removing then re-appending the same child
while (child.getNextSibling() != null) {
child.getNextSibling().removeFromParent();
}
while (child.getPreviousSibling() != null) {
child.getPreviousSibling().removeFromParent();
}
}
}
/**
* Swaps out the old element for the new element.
* The old element's children are added to the new element
*
* @param oldElement
* @param newElement
*/
public static void replaceElement(Element oldElement, Element newElement) {
// TODO(danilatos): Profile this to see if it is faster to move the nodes first,
// and then remove, or the other way round. Profile and optimise some of these
// other methods too. Take dom mutation event handlers being registered into account.
if (oldElement.hasParentElement()) {
oldElement.getParentElement().insertBefore(newElement, oldElement);
oldElement.removeFromParent();
}
DomHelper.moveNodes(newElement, oldElement.getFirstChild(), null, null);
}
/**
* Make an element editable or not
*
* @param element
* @param whiteSpacePreWrap Whether to additionally turn on the white space
* pre wrap property. If in doubt, set to true. This is what we use for
* the editor. So for any concurrently editable areas and such, we must
* use true. If false, does nothing (it does not clear the property).
* @param isEditable
* @return the same element for convenience
*/
public static Element setContentEditable(Element element, boolean isEditable,
boolean whiteSpacePreWrap) {
if (UserAgent.isSafari()) {
// We MUST use the "plaintext-only" variant to prevent nasty things like
// Apple+B munging our dom without giving us a key event.
// Assertion in GWT stuffs this up... fix GWT, in the meantime use a string map
// element.getStyle().setProperty("-webkit-user-modify",
// isEditable ? "read-write-plaintext-only" : "read-only");
JsoView.as(element.getStyle()).setString("-webkit-user-modify",
isEditable ? "read-write-plaintext-only" : "read-only");
} else {
element.setAttribute("contentEditable", isEditable ? "true" : "false");
}
if (whiteSpacePreWrap) {
// More GWT assertion fun!
JsoView.as(element.getStyle()).setString("white-space", "pre-wrap");
}
return element;
}
/**
* Checks whether the given DOM element is editable, either explicitly or
* inherited from its ancestors.
* @param e Element to check
*/
public static boolean isEditable(Element e) {
// special early-exit for problematic shadow dom:
if (isUnreadable(e)) {
return true;
}
Element docElement = Document.get().getDocumentElement();
do {
ElementEditability editability = getElementEditability(e);
if (editability == ElementEditability.NEUTRAL) {
if (e == docElement) {
return false;
}
e = e.getParentElement();
} else {
return editability == ElementEditability.EDITABLE ? true : false;
}
} while (e != null);
// NOTE(danilatos): We didn't hit the body. The only way I know that this can happen
// is if the browser gave us a text node from its SHADOW dom, e.g. in a text box,
// which doesn't have any text node children. I've observed the parent of this text node
// to be reported as a div, and the parent of that div to be null.
return true;
}
public static ElementEditability getElementEditability(Element elem) {
// NOTE(danilatos): This is not necessarily accurate in 100% of situations, with weird
// combinations of editability/enabled etc attributes and tagnames...
String tagName = null;
try {
tagName = elem.getTagName();
} catch (Exception exception) {
// Couldn't get access to the tag name for some reason (see b/2314641).
}
if (tagName != null) {
tagName = tagName.toLowerCase();
if (tagName.equals("input") || tagName.equals("textarea")) {
return ElementEditability.EDITABLE;
}
}
return getContentEditability(elem);
}
/**
* @param element
* @return editability in terms of content-editable only (ignore tag names)
*/
public static ElementEditability getContentEditability(Element element) {
String editability = null;
if (UserAgent.isSafari()) {
JsoView style = JsoView.as(element.getStyle());
editability = style.getString(WEBKIT_USER_MODIFY);
if ("read-write-plaintext-only".equalsIgnoreCase(editability) ||
"read-write".equalsIgnoreCase(editability)) {
return ElementEditability.EDITABLE;
} else if (editability != null && !editability.isEmpty()) {
return ElementEditability.NOT_EDITABLE;
}
// NOTE(danilatos): The css property overrides the contentEditable attribute.
// Still keep going just to check the content editable prop, if no css property set.
}
try {
editability = element.getAttribute("contentEditable");
} catch (JavaScriptException e) {
String elementString = "<couldn't get element string>";
String elementTag = "<couldn't get element tag>";
try {
elementString = element.toString();
} catch (Exception exception) { }
try {
elementTag = element.getTagName();
} catch (Exception exception) { }
StringBuilder sb = new StringBuilder();
sb.append("Couldn't get the 'contentEditable' attribute for element '");
sb.append(elementString).append("' tag name = ").append(elementTag);
throw new RuntimeException(sb.toString(), e);
}
if (editability == null || editability.isEmpty()) {
return ElementEditability.NEUTRAL;
} else {
return "true".equalsIgnoreCase(editability)
? ElementEditability.EDITABLE : ElementEditability.NOT_EDITABLE;
}
}
/**
* Sets the spell check attribute on the element.
* @param enabled true to enable spell check, false to disable.
*/
public static void setNativeSpellCheck(Element element, boolean enabled) {
element.setAttribute("spellcheck", enabled ? "true" : "false");
}
/**
* Makes an element, and all its descendant elements, unselectable.
*/
public static void makeUnselectable(Element e) {
if (UserAgent.isIE()) {
e.setAttribute("unselectable", "on");
e = e.getFirstChildElement();
while (e != null) {
makeUnselectable(e);
e = e.getNextSiblingElement();
}
}
}
/**
* Used to remove event handlers from elements
*
* @see DomHelper#registerEventHandler(Element, String, JavaScriptEventListener)
*/
public static final class HandlerReference extends JavaScriptObject {
/***/
protected HandlerReference() {}
/**
* Unregister a handler registered with
* {@link #registerEventHandler(Element, String, JavaScriptEventListener)} or
* {@link #registerEventHandler(Element, String, boolean, JavaScriptEventListener)}
*
* @return true if the handler was unregistered, false if unregister had
* already been called.
*/
public native boolean unregister() /*-{
var el = this.$el;
if (el == null) {
return false;
}
if (el.removeEventListener) {
el.removeEventListener(this.$ev, this, this.$cp);
} else if (el.detachEvent) {
el.detachEvent('on' + this.$ev, this);
} else {
el['on' + this.$ev] = null;
}
this.$ev = null;
return true;
}-*/;
}
/**
* A set of {@link HandlerReference} for when registering and unregistering a
* handler on multiple events at once.
*/
public static final class HandlerReferenceSet {
public IdentitySet<HandlerReference> references = CollectionUtils.createIdentitySet();
public void unregister() {
Preconditions.checkState(references != null, "References already unregistered");
references.each(new IdentitySet.Proc<HandlerReference>() {
@Override
public void apply(HandlerReference ref) {
ref.unregister();
}
});
references = null;
}
}
/**
* A low level way to register event handlers on dom elements. This differs
* from sinkEvents in that it has nothing to do with widgets, and also allows
* specifying any event name as a string.
*
* NOTE(danilatos): Care must be taken when using this low-level technique,
* you will need to handle your own cleanup to avoid memory leaks.
*
* @param el The dom element on which to listen to events
* @param eventName The name of the event, without any "on-" prefix
* @param listener
* @return a handler to be used with de-registering
*/
public static HandlerReference registerEventHandler(Element el,
String eventName, JavaScriptEventListener listener) {
return registerEventHandler(el, eventName, false, listener);
}
// TODO(danilatos): Split the implementation out into browser-specific versions
/**
* Same as {@link #registerEventHandler(Element, String, JavaScriptEventListener)}
* except provides the (non-cross-browser) capture parameter
*/
public static native HandlerReference registerEventHandler(Element el,
String eventName, boolean capture, JavaScriptEventListener listener) /*-{
var func = $entry(function(e) {
var evt = e || $wnd.event;
listener.
@org.waveprotocol.wave.client.common.util.DomHelper.JavaScriptEventListener::onJavaScriptEvent(Ljava/lang/String;Lcom/google/gwt/user/client/Event;)
(eventName, evt);
});
if (el.addEventListener) {
el.addEventListener(eventName, func, capture);
} else if (el.attachEvent) {
el.attachEvent('on' + eventName, func);
} else {
el['on' + eventName.toLowerCase()] = func;
}
// Setup handler reference object
func.$ev = eventName;
func.$cp = capture;
func.$el = el;
return func;
}-*/;
/**
* Registers a listener for multiple browser events in one go
*
* @param el element to listen on
* @param eventNames set of events
* @param listener
* @return a reference set to be used for unregistering the handler for all
* events in one go
*/
public static HandlerReferenceSet registerEventHandler(final Element el,
ReadableStringSet eventNames, final JavaScriptEventListener listener) {
Preconditions.checkArgument(!eventNames.isEmpty(), "registerEventHandler: Event set is empty");
final HandlerReferenceSet referenceSet = new HandlerReferenceSet();
eventNames.each(new StringSet.Proc() {
@Override
public void apply(String eventName) {
referenceSet.references.add(registerEventHandler(el, eventName, listener));
}
});
return referenceSet;
}
/**
* @return true if it is an element
*/
public static boolean isElement(Node n) {
return n.getNodeType() == Node.ELEMENT_NODE;
}
/**
* @return true if it is a text node
*/
public static boolean isTextNode(Node n) {
return n.getNodeType() == Node.TEXT_NODE;
}
/**
* Finds the index of an element among its parent's children, including
* text nodes.
* @param toFind the node to retrieve the index for
* @return index of element
*
* TODO(danilatos): This could probably be done faster with
* a binary search using text ranges.
* TODO(lars): adapt to non standard browsers.
* TODO(lars): is there a single js call that does this?
*/
public static native int findChildIndex(Node toFind) /*-{
var parent = toFind.parentNode;
var count = 0, child = parent.firstChild;
while (child) {
if (child == toFind)
return count;
if (child.nodeType == 1 || child.nodeType == 3)
++count;
child = child.nextSibling;
}
return -1;
}-*/;
/**
* The last child of element this element. If there is no such element, this
* returns null.
*/
// GWT forgot to add Element.getLastChildElement(), to be symmetric with
// Element.getFirstChildElement().
public static native Element getLastChildElement(Element elem) /*-{
var child = elem.lastChild;
while (child && child.nodeType != 1)
child = child.previousSibling;
return child;
}-*/;
/**
* Gets a list of descendants of e that match the given class name.
*
* If the browser has the native method, that will be called. Otherwise, it
* traverses descendents of the given element and returns the list of elements
* with matching classname.
*
* @param e
* @param className
*/
public static NodeList<Element> getElementsByClassName(Element e, String className) {
if (QuirksConstants.SUPPORTS_GET_ELEMENTS_BY_CLASSNAME) {
return getElementsByClassNameNative(e, className);
} else {
NodeList<Element> all = e.getElementsByTagName("*");
if (all == null) {
return null;
}
JsArray<Element> ret = JavaScriptObject.createArray().cast();
for (int i = 0; i < all.getLength(); ++i) {
Element item = all.getItem(i);
if (className.equals(item.getClassName())) {
ret.push(item);
}
}
return ret.cast();
}
}
private static native NodeList<Element> getElementsByClassNameNative(
Element e, String className) /*-{
return e.getElementsByClassName(className);
}-*/;
/**
* Checks whether the properties of given node cannot be accessed (by testing the nodeType).
*
* It is sometimes the case where we need to access properties of a Node, but the properties
* on that node are not readable (for example, a shadow node like a div created to hold the
* selection within an input field).
*
* In these cases, when the javascript cannot access the node's properties, any attempt to do
* so may cause an internal permissions exception. This method swallows the exception and uses
* its existence to indicate whether or not the node is actually readable.
*
* @param n Node to check
* @return Whether or not the node can have properties read.
*/
public static boolean isUnreadable(Node n) {
try {
n.getNodeType();
return false;
} catch (RuntimeException e) {
return true;
}
}
/**
* Converts a nodelet/offset pair to a Point of Node.
* Just a simple mapping, it is agnostic to inconsistencies, filtered views, etc.
* @param node
* @param offset
* @return html node point
*/
public static Point<Node> nodeOffsetToNodeletPoint(Node node, int offset) {
if (isTextNode(node)) {
return Point.inText(node, offset);
} else {
Element container = node.<Element>cast();
return Point.inElement(container, nodeAfterFromOffset(container, offset));
}
}
/**
* Given a node/offset pair, return the node after the point.
*
* @param container
* @param offset
*/
public static Node nodeAfterFromOffset(Element container, int offset) {
return offset >= container.getChildCount() ? null : container.getChild(offset);
}
}