/*
* DomUtils.java
*
* Copyright (C) 2009-16 by RStudio, Inc.
*
* Unless you have received this program directly from RStudio pursuant
* to the terms of a commercial license agreement with RStudio, then
* this program is licensed to you under the terms of version 3 of the
* GNU Affero General Public License. This program is distributed WITHOUT
* ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
* MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
* AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
*
*/
package org.rstudio.core.client.dom;
import com.google.gwt.core.client.GWT;
import com.google.gwt.core.client.JavaScriptObject;
import com.google.gwt.core.client.JsArrayMixed;
import com.google.gwt.core.client.JsArrayString;
import com.google.gwt.dom.client.*;
import com.google.gwt.dom.client.Element;
import com.google.gwt.event.dom.client.HasAllKeyHandlers;
import com.google.gwt.event.dom.client.KeyCodes;
import com.google.gwt.event.dom.client.KeyDownEvent;
import com.google.gwt.event.dom.client.KeyDownHandler;
import com.google.gwt.event.dom.client.KeyPressEvent;
import com.google.gwt.event.dom.client.KeyPressHandler;
import com.google.gwt.event.dom.client.KeyUpEvent;
import com.google.gwt.event.dom.client.KeyUpHandler;
import com.google.gwt.user.client.*;
import com.google.gwt.user.client.ui.Label;
import com.google.gwt.user.client.ui.RootPanel;
import com.google.gwt.user.client.ui.UIObject;
import org.rstudio.core.client.BrowseCap;
import org.rstudio.core.client.Debug;
import org.rstudio.core.client.Point;
import org.rstudio.core.client.Rectangle;
import org.rstudio.core.client.command.KeyboardShortcut;
import org.rstudio.core.client.dom.impl.DomUtilsImpl;
import org.rstudio.core.client.dom.impl.NodeRelativePosition;
import org.rstudio.core.client.regex.Match;
import org.rstudio.core.client.regex.Pattern;
import org.rstudio.core.client.theme.res.ThemeStyles;
import org.rstudio.core.client.widget.FontSizer;
import org.rstudio.studio.client.application.Desktop;
/**
* Helper methods that are mostly useful for interacting with
* contentEditable regions.
*/
public class DomUtils
{
public interface NodePredicate
{
boolean test(Node n) ;
}
public static native Element getActiveElement() /*-{
return $doc.activeElement;
}-*/;
/**
* In IE8, focusing the history table (which is larger than the scroll
* panel it's contained in) causes the scroll panel to jump to the top.
* Using setActive() solves this problem. Other browsers don't support
* setActive but also don't have the scrolling problem.
* @param element
*/
public static native void setActive(Element element) /*-{
if (element.setActive)
element.setActive();
else
element.focus();
}-*/;
/**
* Trim excess lines from the beginning of the text of an element.
*
* @param element The element to trim lines from.
* @param linesToTrim The number of lines to trim.
* @return Number of lines trimmed
*/
public static int trimLines(Element element, int linesToTrim)
{
return trimLines(element.getChildNodes(), linesToTrim);
}
public static native void scrollToBottom(Element element) /*-{
element.scrollTop = element.scrollHeight;
}-*/;
public static JavaScriptObject splice(JavaScriptObject array,
int index,
int howMany,
String... elements)
{
JsArrayMixed args = JavaScriptObject.createArray().cast();
args.push(index);
args.push(howMany);
for (String el : elements)
args.push(el);
return spliceInternal(array, args);
}
private static native JsArrayString spliceInternal(JavaScriptObject array,
JsArrayMixed args) /*-{
return Array.prototype.splice.apply(array, args);
}-*/;
public static Node findNodeUpwards(Node node,
Element scope,
NodePredicate predicate)
{
if (scope != null && !scope.isOrHasChild(node))
throw new IllegalArgumentException("Incorrect scope passed to findParentNode");
for (; node != null; node = node.getParentNode())
{
if (predicate.test(node))
return node;
if (scope == node)
return null;
}
return null;
}
public static boolean isEffectivelyVisible(Element element)
{
while (element != null)
{
if (!UIObject.isVisible(element))
return false;
// If element never equals body, then the element is not attached
if (element == Document.get().getBody())
return true;
element = element.getParentElement();
}
// Element is not attached
return false;
}
public static void selectElement(Element el)
{
impl.selectElement(el);
}
private static final Pattern NEWLINE = Pattern.create("\\n");
private static int trimLines(NodeList<Node> nodes, final int linesToTrim)
{
if (nodes == null || nodes.getLength() == 0 || linesToTrim == 0)
return 0;
int linesLeft = linesToTrim;
Node node = nodes.getItem(0);
while (node != null && linesLeft > 0)
{
switch (node.getNodeType())
{
case Node.ELEMENT_NODE:
if (((Element)node).getTagName().equalsIgnoreCase("br"))
{
linesLeft--;
node = removeAndGetNext(node);
continue;
}
else
{
int trimmed = trimLines(node.getChildNodes(), linesLeft);
linesLeft -= trimmed;
if (!node.hasChildNodes())
node = removeAndGetNext(node);
continue;
}
case Node.TEXT_NODE:
String text = ((Text)node).getData();
Match lastMatch = null;
Match match = NEWLINE.match(text, 0);
while (match != null && linesLeft > 0)
{
lastMatch = match;
linesLeft--;
match = match.nextMatch();
}
if (linesLeft > 0 || lastMatch == null)
{
node = removeAndGetNext(node);
continue;
}
else
{
int index = lastMatch.getIndex() + 1;
if (text.length() == index)
node.removeFromParent();
else
((Text) node).deleteData(0, index);
break;
}
}
}
return linesToTrim - linesLeft;
}
private static Node removeAndGetNext(Node node)
{
Node next = node.getNextSibling();
node.removeFromParent();
return next;
}
/**
*
* @param node
* @param pre Count hard returns in text nodes as newlines (only true if
* white-space mode is pre*)
* @return
*/
public static int countLines(Node node, boolean pre)
{
switch (node.getNodeType())
{
case Node.TEXT_NODE:
return countLinesInternal((Text)node, pre);
case Node.ELEMENT_NODE:
return countLinesInternal((Element)node, pre);
default:
return 0;
}
}
private static int countLinesInternal(Text textNode, boolean pre)
{
if (!pre)
return 0;
String value = textNode.getData();
Pattern pattern = Pattern.create("\\n");
int count = 0;
Match m = pattern.match(value, 0);
while (m != null)
{
count++;
m = m.nextMatch();
}
return count;
}
private static int countLinesInternal(Element elementNode, boolean pre)
{
if (elementNode.getTagName().equalsIgnoreCase("br"))
return 1;
int result = 0;
NodeList<Node> children = elementNode.getChildNodes();
for (int i = 0; i < children.getLength(); i++)
result += countLines(children.getItem(i), pre);
return result;
}
private final static DomUtilsImpl impl = GWT.create(DomUtilsImpl.class);
/**
* Drives focus to the element, and if (the element is contentEditable and
* contains no text) or alwaysDriveSelection is true, also drives window
* selection to the contents of the element. This is sometimes necessary
* to get focus to move at all.
*/
public static void focus(Element element, boolean alwaysDriveSelection)
{
impl.focus(element, alwaysDriveSelection);
}
public static native boolean hasFocus(Element element) /*-{
return element === $doc.activeElement;
}-*/;
public static void collapseSelection(boolean toStart)
{
impl.collapseSelection(toStart);
}
public static boolean isSelectionCollapsed()
{
return impl.isSelectionCollapsed();
}
public static boolean isSelectionInElement(Element element)
{
return impl.isSelectionInElement(element);
}
/**
* Returns true if the window contains an active selection.
*/
public static boolean selectionExists()
{
return impl.selectionExists();
}
public static boolean contains(Element container, Node descendant)
{
while (descendant != null)
{
if (descendant == container)
return true ;
descendant = descendant.getParentNode() ;
}
return false ;
}
/**
* CharacterData.deleteData(node, index, offset)
*/
public static final native void deleteTextData(Text node,
int offset,
int length) /*-{
node.deleteData(offset, length);
}-*/;
public static native void insertTextData(Text node,
int offset,
String data) /*-{
node.insertData(offset, data);
}-*/;
public static Rectangle getCursorBounds()
{
return getCursorBounds(Document.get()) ;
}
public static Rectangle getCursorBounds(Document doc)
{
return impl.getCursorBounds(doc);
}
public static String replaceSelection(Document document, String text)
{
return impl.replaceSelection(document, text);
}
public static String getSelectionText(Document document)
{
return impl.getSelectionText(document);
}
public static int[] getSelectionOffsets(Element container)
{
return impl.getSelectionOffsets(container);
}
public static void setSelectionOffsets(Element container,
int start,
int end)
{
impl.setSelectionOffsets(container, start, end);
}
public static Text splitTextNodeAt(Element container, int offset)
{
NodeRelativePosition pos = NodeRelativePosition.toPosition(container, offset) ;
if (pos != null)
{
return ((Text)pos.node).splitText(pos.offset) ;
}
else
{
Text newNode = container.getOwnerDocument().createTextNode("");
container.appendChild(newNode);
return newNode;
}
}
public static native Element getTableCell(Element table, int row, int col) /*-{
return table.rows[row].cells[col] ;
}-*/;
public static void dump(Node node, String label)
{
StringBuffer buffer = new StringBuffer() ;
dump(node, "", buffer, false) ;
Debug.log("Dumping " + label + ":\n\n" + buffer.toString()) ;
}
private static void dump(Node node,
String indent,
StringBuffer out,
boolean doSiblings)
{
if (node == null)
return ;
out.append(indent)
.append(node.getNodeName()) ;
if (node.getNodeType() != 1)
{
out.append(": \"")
.append(node.getNodeValue())
.append("\"");
}
out.append("\n") ;
dump(node.getFirstChild(), indent + "\u00A0\u00A0", out, true) ;
if (doSiblings)
dump(node.getNextSibling(), indent, out, true) ;
}
public static native void ensureVisibleVert(
Element container,
Element child,
int padding) /*-{
if (!child)
return;
var height = child.offsetHeight ;
var top = 0;
while (child && child != container)
{
top += child.offsetTop ;
child = child.offsetParent ;
}
if (!child)
return;
// padding
top -= padding;
height += padding*2;
if (top < container.scrollTop)
{
container.scrollTop = top ;
}
else if (container.scrollTop + container.offsetHeight < top + height)
{
container.scrollTop = top + height - container.offsetHeight ;
}
}-*/;
// Forked from com.google.gwt.dom.client.Element.scrollIntoView()
public static native void scrollIntoViewVert(Element elem) /*-{
var top = elem.offsetTop;
var height = elem.offsetHeight;
if (elem.parentNode != elem.offsetParent) {
top -= elem.parentNode.offsetTop;
}
var cur = elem.parentNode;
while (cur && (cur.nodeType == 1)) {
if (top < cur.scrollTop) {
cur.scrollTop = top;
}
if (top + height > cur.scrollTop + cur.clientHeight) {
cur.scrollTop = (top + height) - cur.clientHeight;
}
var offsetTop = cur.offsetTop;
if (cur.parentNode != cur.offsetParent) {
offsetTop -= cur.parentNode.offsetTop;
}
top += offsetTop - cur.scrollTop;
cur = cur.parentNode;
}
}-*/;
public static Point getRelativePosition(Element container,
Element child)
{
int left = 0, top = 0;
while (child != null && child != container)
{
left += child.getOffsetLeft();
top += child.getOffsetTop();
child = child.getOffsetParent();
}
return new Point(left, top);
}
public static int ensureVisibleHoriz(Element container,
Element child,
int paddingLeft,
int paddingRight,
boolean calculateOnly)
{
final int scrollLeft = container.getScrollLeft();
if (child == null)
return scrollLeft;
int width = child.getOffsetWidth();
int left = getRelativePosition(container, child).x;
left -= paddingLeft;
width += paddingLeft + paddingRight;
int result;
if (left < scrollLeft)
result = left;
else if (scrollLeft + container.getOffsetWidth() < left + width)
result = left + width - container.getOffsetWidth();
else
result = scrollLeft;
if (!calculateOnly && result != scrollLeft)
container.setScrollLeft(result);
return result;
}
public static native boolean isVisibleVert(Element container,
Element child) /*-{
if (!container || !child)
return false;
var height = child.offsetHeight;
var top = 0;
while (child && child != container)
{
top += child.offsetTop ;
child = child.offsetParent ;
}
if (!child)
throw new Error("Child was not in container or " +
"container wasn't offset parent");
var bottom = top + height;
var scrollTop = container.scrollTop;
var scrollBottom = container.scrollTop + container.clientHeight;
return (top > scrollTop && top < scrollBottom)
|| (bottom > scrollTop && bottom < scrollBottom);
}-*/;
public static String getHtml(Node node)
{
switch (node.getNodeType())
{
case Node.DOCUMENT_NODE:
return ((ElementEx)node).getOuterHtml() ;
case Node.ELEMENT_NODE:
return ((ElementEx)node).getOuterHtml() ;
case Node.TEXT_NODE:
return node.getNodeValue() ;
default:
assert false :
"Add case statement for node type " + node.getNodeType() ;
return node.getNodeValue() ;
}
}
public static boolean isDescendant(Node el, Node ancestor)
{
for (Node parent = el.getParentNode();
parent != null;
parent = parent.getParentNode())
{
if (parent.equals(ancestor))
return true ;
}
return false ;
}
public static boolean isDescendantOfElementWithTag(Element el, String[] tags)
{
for (Element parent = el.getParentElement();
parent != null;
parent = parent.getParentElement())
{
for (String tag : tags)
if (tag.toLowerCase().equals(parent.getTagName().toLowerCase()))
return true;
}
return false ;
}
/**
* Finds a node that matches the predicate.
*
* @param start The node from which to start.
* @param recursive If true, recurses into child nodes.
* @param siblings If true, looks at the next sibling from "start".
* @param filter The predicate that determines a match.
* @return The first matching node encountered in documented order, or null.
*/
public static Node findNode(Node start,
boolean recursive,
boolean siblings,
NodePredicate filter)
{
if (start == null)
return null ;
if (filter.test(start))
return start ;
if (recursive)
{
Node result = findNode(start.getFirstChild(), true, true, filter) ;
if (result != null)
return result ;
}
if (siblings)
{
Node result = findNode(start.getNextSibling(), recursive, true,
filter) ;
if (result != null)
return result ;
}
return null ;
}
/**
* Converts plaintext to HTML, preserving whitespace semantics
* as much as possible.
*/
public static String textToHtml(String text)
{
// Order of these replacement operations is important.
return
text.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll("\\n", "<br />")
.replaceAll("\\t", " ")
.replaceAll(" ", " ")
.replaceAll(" (?! )", " ")
.replaceAll(" $", " ")
.replaceAll("^ ", " ");
}
public static String textToPreHtml(String text)
{
// Order of these replacement operations is important.
return
text.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll("\\t", " ");
}
public static String htmlToText(String html)
{
Element el = DOM.createSpan();
el.setInnerHTML(html);
return el.getInnerText();
}
/**
* Similar to Element.getInnerText() but converts br tags to newlines.
*/
public static String getInnerText(Element el)
{
return getInnerText(el, false);
}
public static String getInnerText(Element el, boolean pasteMode)
{
StringBuilder out = new StringBuilder();
getInnerText(el, out, pasteMode);
return out.toString();
}
private static void getInnerText(Node node,
StringBuilder out,
boolean pasteMode)
{
if (node == null)
return;
for (Node child = node.getFirstChild();
child != null;
child = child.getNextSibling())
{
switch (child.getNodeType())
{
case Node.TEXT_NODE:
out.append(child.getNodeValue());
break;
case Node.ELEMENT_NODE:
Element childEl = (Element) child;
String tag = childEl.getTagName().toLowerCase();
// Sometimes when pasting text (e.g. from IntelliJ) into console
// the line breaks turn into <br _moz_dirty="true"/> or whatever.
// We want to keep them in those cases. But in other cases
// the _moz_dirty breaks are just spurious.
if (tag.equals("br") && (pasteMode || !childEl.hasAttribute("_moz_dirty")))
out.append("\n");
else if (tag.equals("script") || tag.equals("style"))
continue;
getInnerText(child, out, pasteMode);
break;
}
}
}
public static void setInnerText(Element el, String plainText)
{
el.setInnerText("");
if (plainText == null || plainText.length() == 0)
return;
Document doc = el.getOwnerDocument();
Pattern pattern = Pattern.create("\\n");
int tail = 0;
Match match = pattern.match(plainText, 0);
while (match != null)
{
if (tail != match.getIndex())
{
String line = plainText.substring(tail, match.getIndex());
el.appendChild(doc.createTextNode(line));
}
el.appendChild(doc.createBRElement());
tail = match.getIndex() + 1;
match = match.nextMatch();
}
if (tail < plainText.length())
el.appendChild(doc.createTextNode(plainText.substring(tail)));
}
public static boolean isSelectionAsynchronous()
{
return impl.isSelectionAsynchronous();
}
public static boolean isCommandClick(NativeEvent nativeEvt)
{
int modifierKeys = KeyboardShortcut.getModifierValue(nativeEvt);
boolean isCommandPressed = BrowseCap.isMacintosh() ?
modifierKeys == KeyboardShortcut.META :
modifierKeys == KeyboardShortcut.CTRL;
return (nativeEvt.getButton() == NativeEvent.BUTTON_LEFT) && isCommandPressed;
}
// Returns the relative vertical position of a child to its parent.
// Presumes that the parent is one of the elements from which the child's
// position is computed; if this is not the case, the child's position
// relative to the body is returned.
public static int topRelativeTo(Element parent, Element child)
{
int top = 0;
Element el = child;
while (el != null && el != parent)
{
top += el.getOffsetTop();
el = el.getOffsetParent();
}
return top;
}
public static int bottomRelativeTo(Element parent, Element child)
{
return topRelativeTo(parent, child) + child.getOffsetHeight();
}
public static int leftRelativeTo(Element parent, Element child)
{
int left = 0;
Element el = child;
while (el != null && el != parent)
{
left += el.getOffsetLeft();
el = el.getOffsetParent();
}
return left;
}
public static final native void setStyle(Element element,
String name,
String value) /*-{
element.style[name] = value;
}-*/;
public static native final Element getElementById(String id) /*-{
return $doc.getElementById(id);
}-*/;
public static Element[] getElementsByClassName(String classes)
{
Element documentEl = Document.get().cast();
return getElementsByClassName(documentEl, classes);
}
public static final native Element[] getElementsByClassName(Element parent, String classes) /*-{
var result = [];
var elements = parent.getElementsByClassName(classes);
for (var i = 0; i < elements.length; i++) {
result.push(elements[i]);
}
return result;
}-*/;
public static final Element getFirstElementWithClassName(Element parent, String classes)
{
Element[] elements = getElementsByClassName(parent, classes);
if (elements.length == 0)
return null;
return elements[0];
}
public static final Element getParent(Element element, int times)
{
Element parent = element;
for (int i = 0; i < times; i++)
{
if (parent == null) return null;
parent = parent.getParentElement();
}
return parent;
}
// NOTE: Not supported in IE8
public static final native Style getComputedStyles(Element el)
/*-{
return $wnd.getComputedStyle(el);
}-*/;
public static void toggleClass(Element element,
String cssClass,
boolean value)
{
if (value && !element.hasClassName(cssClass))
element.addClassName(cssClass);
if (!value && element.hasClassName(cssClass))
element.removeClassName(cssClass);
}
public interface NativeEventHandler
{
public void onNativeEvent(NativeEvent event);
}
public static void addKeyHandlers(HasAllKeyHandlers widget,
final NativeEventHandler handler)
{
widget.addKeyDownHandler(new KeyDownHandler()
{
@Override
public void onKeyDown(final KeyDownEvent event)
{
handler.onNativeEvent(event.getNativeEvent());
}
});
widget.addKeyPressHandler(new KeyPressHandler()
{
@Override
public void onKeyPress(final KeyPressEvent event)
{
handler.onNativeEvent(event.getNativeEvent());
}
});
widget.addKeyUpHandler(new KeyUpHandler()
{
@Override
public void onKeyUp(final KeyUpEvent event)
{
handler.onNativeEvent(event.getNativeEvent());
}
});
}
public interface ElementPredicate
{
public boolean test(Element el);
}
public static Element findParentElement(Element el,
ElementPredicate predicate)
{
return findParentElement(el, false, predicate);
}
public static Element findParentElement(Element el,
boolean includeSelf,
ElementPredicate predicate)
{
Element parent = includeSelf ? el : el.getParentElement();
while (parent != null)
{
if (predicate.test(parent))
return parent;
parent = parent.getParentElement();
}
return null;
}
public final static native Element elementFromPoint(int x, int y) /*-{
return $doc.elementFromPoint(x, y);
}-*/;
public static final native void setSelectionRange(Element el, int start, int end)
/*-{
if (el.setSelectionRange)
el.setSelectionRange(start, end);
}-*/;
public static final native void copyCodeToClipboard(String text) /*-{
var copyElem = document.createElement('pre');
copyElem.contentEditable = true;
document.body.appendChild(copyElem);
copyElem.innerHTML = text;
copyElem.unselectable = "off";
copyElem.focus();
document.execCommand('SelectAll');
document.execCommand("Copy", false, null);
document.body.removeChild(copyElem);
}-*/;
public static final String extractCssValue(String className,
String propertyName)
{
JsArrayString classes = JsArrayString.createArray().cast();
classes.push(className);
return extractCssValue(classes, propertyName);
}
public static final boolean preventBackspaceCausingBrowserBack(NativeEvent event)
{
if (Desktop.isDesktop())
return false;
if (event.getKeyCode() != KeyCodes.KEY_BACKSPACE)
return false;
EventTarget target = event.getEventTarget();
if (target == null)
return false;
Element elementTarget = Element.as(target);
if (!elementTarget.getNodeName().equals("BODY"))
return false;
event.preventDefault();
return true;
}
public static final native String extractCssValue(JsArrayString className,
String propertyName) /*-{
// A more elegant way of performing this would be to iterate through the
// document's styleSheet collection, but unfortunately browsers don't
// expose the cssRules in all cases
var ele = null, parent = null, root = null;
for (var i = 0; i < className.length; i++)
{
ele = $doc.createElement("div");
ele.style.display = "none";
ele.className = className[i];
if (parent != null)
parent.appendChild(ele);
parent = ele;
if (root == null)
root = ele;
}
$doc.body.appendChild(root);
var computed = $wnd.getComputedStyle(ele);
var result = computed[propertyName] || "";
$doc.body.removeChild(root);
return result;
}-*/;
public static int getCharacterWidth(int clientWidth, int offsetWidth,
String style)
{
// create width checker label and add it to the root panel
Label widthChecker = new Label();
widthChecker.setStylePrimaryName(style);
FontSizer.applyNormalFontSize(widthChecker);
RootPanel.get().add(widthChecker, -1000, -1000);
// put the text into the label, measure it, and remove it
String text = new String("abcdefghijklmnopqrstuvwzyz0123456789");
widthChecker.setText(text);
int labelWidth = widthChecker.getOffsetWidth();
RootPanel.get().remove(widthChecker);
// compute the points per character
float pointsPerCharacter = (float)labelWidth / (float)text.length();
// compute client width
if (clientWidth == offsetWidth)
{
// if the two widths are the same then there are no scrollbars.
// however, we know there will eventually be a scrollbar so we
// should offset by an estimated amount
// (is there a more accurate way to estimate this?)
clientWidth -= ESTIMATED_SCROLLBAR_WIDTH;
}
// compute character width (add pad so characters aren't flush to right)
final int RIGHT_CHARACTER_PAD = 2;
int width = Math.round((float)clientWidth / pointsPerCharacter) -
RIGHT_CHARACTER_PAD;
// enforce a minimum width
final int MINIMUM_WIDTH = 30;
return Math.max(width, MINIMUM_WIDTH);
}
public static int getCharacterWidth(Element ele, String style)
{
return getCharacterWidth(ele.getClientWidth(), ele.getOffsetWidth(),
style);
}
public static void toggleParentVisibility(Element el, boolean visible, ElementPredicate predicate)
{
Element parentEl = el.getParentElement();
if (parentEl == null)
return;
if (predicate != null && !predicate.test(parentEl))
return;
toggleClass(parentEl, ThemeStyles.INSTANCE.displayNone(), !visible);
}
public static final int ESTIMATED_SCROLLBAR_WIDTH = 19;
}