/** * Copyright 2008 Google Inc. * * 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 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); } }