/* * Copyright 2010 John Kozura * * 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.bfr.client.selection; 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.Style.Position; import com.google.gwt.dom.client.Style.Unit; import com.google.gwt.dom.client.Text; import com.google.gwt.regexp.shared.RegExp; /** * An end point of a range, represented as a text node and offset in to it. * Does not support potential other types of selection end points. * * @author John Kozura */ public class RangeEndPoint implements Comparable<RangeEndPoint> { public static final short MOVE_CHARACTER = 1; public static final short MOVE_WORDSTART = 2; public static final short MOVE_WORDEND = 3; // All unicode whitespace characters public static final String DEFAULT_WS_REXP = "[\t-\r \u0085\u00A0\u1680\u180E\u2000-\u200B\u2028\u2029\u202F\u205F\u3000\uFEFF]+"; @SuppressWarnings("unused") private static RegExp c_wsRexp; /** * Set the regular expression used for detecting consecutive whitespace in a * string. It must be of the form "[ \t\n]+", with all desired whitespace * characters between the braces. This is used for word detection for * the move method. * * @param regExp String of the regular expression */ public static void setWhitespaceRexp(String regExp) { c_wsRexp = RegExp.compile(regExp, "gm"); } /** * The node containing the start/end of the selection. This can be a * Text for normal text selections, or an Element for nontextual, for * instance an image. */ private Node m_node; /** * The number of characters starting from the beginning of the textNode * where the selection begins/ends. */ private int m_offset; /** * Create a range end point with nothing set */ public RangeEndPoint() { super(); } /** * Create a range end point at the start or end of an element. The actual * selection will occur at the first/last text node within this element. * * @param element element to create this end point in * @param start whether to make the end point at the start or the end */ public RangeEndPoint(Element element, boolean start) { this(); setElement(element, start); } /** * Create a non-textual range end point that just points to this element * * @param element Non-textual element to point to */ public RangeEndPoint(Element element) { this(); setElement(element); } /** * Create a range end point at the start or end of a text node * * @param text text node this end point starts/end in * @param start whether to make the end point at the start or the end */ public RangeEndPoint(Text text, boolean start) { this(); setTextNode(text, start); } /** * Create a range end point with a text node and offset into it * * @param text text node this end point occurs in * @param offset offset characters into the text node */ public RangeEndPoint(Text text, int offset) { this(); setTextNode(text); setOffset(offset); } /** * Clone a range end point * * @param endPoint point to clone */ public RangeEndPoint(RangeEndPoint endPoint) { this(endPoint.getTextNode(), endPoint.getOffset()); } @Override public int compareTo(RangeEndPoint cmp) { Range thisRng = new Range(this); Range cmpRng = new Range(cmp); return thisRng.compareBoundaryPoint(cmpRng, Range.START_TO_START); } @Override public boolean equals(Object obj) { boolean res = false; try { RangeEndPoint cmp = (RangeEndPoint) obj; res = (cmp == this) || ((cmp.getNode() == getNode()) && (cmp.getOffset() == getOffset())); } catch (Exception ex) { } return res; } /** * Get the offset into the text node * * @return offset in characters */ public int getOffset() { return m_offset; } /** * Get the string of the text node of this end point, either up to or * starting from the offset: * <p/> * "som|e text" * true : "e text" * false : "som" * * @param asStart whether to get the text as if this is a start point * @return the text before or after the offset, or null if this is not set */ public String getString(boolean asStart) { if (!isTextNode()) { return null; } String res = ((Text) m_node).getData(); return asStart ? res.substring(m_offset) : res.substring(0, m_offset); } /** * Get this as a node (be it text or element) */ public Node getNode() { return m_node; } /** * Get the text node of this end point, note this can be null if there are * no text anchors available or if this is just an element. * * @return the text node */ public Text getTextNode() { return isTextNode() ? (Text) m_node : null; } /** * Get the Element of this end point, if this is not a textual end point. * * @return the text node */ public Element getElement() { return isTextNode() ? null : (Element) m_node; } @SuppressWarnings("deprecation") public boolean isSpace(Character check) { return Character.isSpace(check); } public boolean isTextNode() { return (m_node == null) ? false : (m_node.getNodeType() == Node.TEXT_NODE); } /** * If the offset occurs at the beginning/end of the text node, potentially * move to the end/beginning of the next/previous text node, to remove * text nodes where 0 characters are actually used. If asStart is true then * move a cursor at the end of a text node to the beginning of the next; * vice versa for false. * * @param asStart Whether to do this as a start or end range point */ public void minimizeBoundaryTextNodes(boolean asStart) { Text text = getTextNode(); if ((text != null) && (m_offset == (asStart ? text.getLength() : 0))) { Text next = Range.getAdjacentTextElement(text, asStart); if (next != null) { setTextNode(next); m_offset = asStart ? 0 : next.getLength(); } } } /** * TODO IMPLEMENTED ONLY FOR CHARACTER * Move the end point forwards or backwards by one unit of type, such as * by a word. * * @param forward true if moving forward * @param topMostNode top node to not move past, or null * @param limit an endpoint not to move past, or null * @param type what unit to move by, ie MOVE_CHARACTER or MOVE_WORD * @param count how many of these to move by * @return how far this actually moved */ public int move(boolean forward, Node topMostNode, RangeEndPoint limit, short type, int count) { int res = 0; Text limitText = (limit == null) ? null : limit.getTextNode(); Text curr = getTextNode(); if (curr != null) { Text last = curr; int offset = getOffset(); switch (type) { case MOVE_CHARACTER: while (curr != null) { last = curr; int len = forward ? curr.getLength() - offset : offset; if (curr == limitText) { // If there is a limiting endpoint, may not be able to // go as far len = forward ? (limit.getOffset() - offset) : (offset - limit.getOffset()); } if ((len + res) < count) { res += len; if (curr == limitText) { break; } } else { // Finis len = count - res; offset = forward ? (offset + len) : offset - len; res = count; break; } do { // Next node, skipping any 0-length texts curr = Range.getAdjacentTextElement(curr, topMostNode, forward, false); } while ((curr != null) && (curr.getLength() == 0)); if (curr != null) { offset = forward ? 0 : curr.getLength(); } } break; /* case MOVE_WORDSTART: case MOVE_WORDEND: if (c_wsRexp == null) { setWhitespaceRexp(DEFAULT_WS_REXP); } while (curr != null) { do { // Next node, skipping any 0-length texts curr = Range.getAdjacentTextElement(curr, topMostNode, forward, false); } while ((curr != null) && (curr.getLength() == 0)); if (curr != null) { offset = forward ? 0 : curr.getLength(); } } break; */ default: assert (false); } setTextNode(last); setOffset(offset); } return res; } /** * Sets this start point to be a non-textual element (like an image) */ public void setElement(Element element) { m_node = element; } /** * Set the range end point at the start or end of an element. The actual * selection will occur at the first/last text node within this element. * * @param element element to set this end point in * @param start whether to make the end point at the start or the end */ public void setElement(Element element, boolean start) { Text text = Range.getAdjacentTextElement(element, element, start, false); setTextNode(text, start); } /** * Given an absolute x/y coordinate and an element where that coordinate * falls (generally obtained from an event), creates a RangeEndPoint * containing or closest to the coordinate. If the point falls within a * non-textual element, a non-text endpoint is returned. If the point falls * within a text-containing element but not within any of the actual child * text, tries to find the closest text point. * * @param element An element this point falls within * @param absX Absolute X coordinate, ie from Event.getClientX * @param absY Absolute Y coordinate, ie from Event.getClientY * @return A rangeendpoint where the click occured, or null if not found */ private static Element spn; public static RangeEndPoint findLocation(Element element, int absX, int absY) { // Convert to document-relative coordinates Document doc = element.getOwnerDocument(); int relX = absX - doc.getBodyOffsetLeft(); int offY = getTotalOffsetY(doc); int relY = absY + offY; if (spn == null) { spn = doc.createSpanElement(); spn.setInnerText("X"); } Element body = doc.getBody(); body.appendChild(spn); spn.getStyle().setPosition(Position.ABSOLUTE); spn.getStyle().setTop(relY, Unit.PX); spn.getStyle().setLeft(relX, Unit.PX); FindLocRes locRes = findLocation(doc, element, relX, relY); return (locRes == null) ? null : locRes.ep; } private static native int getTotalOffsetY(Document doc) /*-{ var res = 0; var wind = doc.defaultView || doc.parentWindow; if (wind) { res = wind.pageYOffset; } return res; }-*/; /* if (wind.mozInnerScreenX) { res = res + wind.mozInnerScreenX; } else if (wind.screenTop) { res = res + wind.screenTop; } else { // webkit? } */ private static class FindLocRes { RangeEndPoint ep; int distance; public FindLocRes(RangeEndPoint ept) { this(ept, 0); } public FindLocRes(RangeEndPoint ept, int dist) { ep = ept; distance = dist; } public static FindLocRes replace(FindLocRes curr, FindLocRes comp) { FindLocRes res = curr; if ((comp != null) && ((curr == null) || (curr.ep == null) || (comp.distance < curr.distance))) { res = comp; } return res; } public boolean isExact() { return (ep != null) && (distance == 0); } } private static FindLocRes findLocation(Document doc, Element ele, int relX, int relY) { FindLocRes res = null; if (contains(ele, relX, relY) && isVisible(doc, ele)) { if (ele.hasChildNodes()) { // Iterate through children until we hit an exact match for (int i = 0; (i < ele.getChildCount()) && ((res == null) || !res.isExact()); i++) { FindLocRes tmp; Node child = ele.getChild(i); if (child.getNodeType() == Node.ELEMENT_NODE) { tmp = findLocation(doc, (Element) child, relX, relY); } else { tmp = findLocation(doc, (Text) child, relX, relY); } res = FindLocRes.replace(res, tmp); } } else { // If this contains but has no children, then this is it res = new FindLocRes(new RangeEndPoint(ele)); } } return res; } private static FindLocRes findLocation(Document doc, Text text, int relX, int relY) { FindLocRes res = null; String str = text.getData(); if ((str == null) || str.isEmpty()) { // Theoretically it could be in here still.. } else { // Insert 2 spans and do a binary search to find the single // character that fits Element span1 = doc.createSpanElement(); Element span2 = doc.createSpanElement(); Element span3 = doc.createSpanElement(); Element span4 = doc.createSpanElement(); Element parent = text.getParentElement(); parent.insertBefore(span1, text); parent.insertBefore(span2, text); parent.insertBefore(span3, text); parent.insertBefore(span4, text); parent.removeChild(text); try { int len = str.length() / 2; span2.setInnerText(str.substring(0, len)); span3.setInnerText(str.substring(len)); res = findLocation(text, span1, span2, span3, span4, relX, relY); } catch (Exception ex) { } finally { parent.insertAfter(text, span4); parent.removeChild(span1); parent.removeChild(span2); parent.removeChild(span3); parent.removeChild(span4); } } return res; } private static FindLocRes findLocation(Text origText, Element span1, Element span2, Element span3, Element span4, int relX, int relY) { FindLocRes res = null; while (res == null) { if (contains(span2, relX, relY)) { String str = span2.getInnerText(); if (str.length() <= 1) { res = new FindLocRes( new RangeEndPoint(origText, span1.getInnerText().length() + closerOffset(span2, relX))); } else { span4.setInnerHTML(span3.getInnerHTML() + span4.getInnerHTML()); int len = str.length() / 2; span2.setInnerHTML(str.substring(0, len)); span3.setInnerHTML(str.substring(len)); } } else if (contains(span3, relX, relY)) { String str = span3.getInnerText(); if (str.length() <= 1) { res = new FindLocRes( new RangeEndPoint(origText, span1.getInnerText().length() + span2.getInnerHTML().length() + closerOffset(span3, relX))); } else { span1.setInnerHTML(span1.getInnerHTML() + span2.getInnerHTML()); int len = str.length() / 2; span2.setInnerHTML(str.substring(0, len)); span3.setInnerHTML(str.substring(len)); } } else { // This might be close to one end or the other of this int dist1 = getLocDistance(span1.hasChildNodes() ? span2 : span1, relX, relY); int dist2 = getLocDistance(span4.hasChildNodes() ? span4 : span3, relX, relY); res = new FindLocRes(new RangeEndPoint(origText, dist1 < dist2), Math.min(dist1, dist2)); } } return res; } // 0 if closer to the left edge, 1 if closer to the right. private static int closerOffset(Element ele, int relX) { return ((relX - ele.getAbsoluteLeft()) <= (ele.getAbsoluteRight() - relX)) ? 0 : 1; } private static boolean contains(Element ele, int relX, int relY) { /* int l = ele.getAbsoluteLeft(); int r = ele.getAbsoluteRight(); int t = ele.getAbsoluteTop(); int b = ele.getAbsoluteBottom(); */ return ((ele.getAbsoluteLeft() <= relX) && (ele.getAbsoluteRight() >= relX) && (ele.getAbsoluteTop() <= relY) && (ele.getAbsoluteBottom() >= relY)); } private static int getLocDistance(Element ele, int relX, int relY) { int top = ele.getAbsoluteTop(); int bot = ele.getAbsoluteBottom(); int res = 0; if (relY < bot) { res = bot - relY; } else if (relY > top) { res = relY - top; } int left = ele.getAbsoluteLeft(); int right = ele.getAbsoluteRight(); if (relX < left) { res += left - relX; } else if (relX > right) { res += right - relX; } return res; } private static native boolean isVisible(Document doc, Element ele) /*-{ if (!ele.parentNode) return false; if (ele.style) { if (ele.style.display == 'none') return false; if (ele.style.visibility == 'hidden') return false; } // Try the computed style in a standard way var wind = doc.defaultView || doc.parentWindow; if (wind && wind.getComputedStyle) { var style = wind.getComputedStyle(ele, null); if (style.display == 'none') return false; if (style.visibility == 'hidden') return false; } // Don't care about parents, already traversed down them //return isVisible(obj.parentNode); return true; }-*/; /* // Or get the computed style using IE's silly proprietary way // I think IE supports getComputedStyle now var style = obj.currentStyle; if (style) { if (style['display'] == 'none') return false; if (style['visibility'] == 'hidden') return false; } */ /** * Set the offset into the text node * * @param offset offset in characters */ public void setOffset(int offset) { m_offset = offset; } /** * Set the text node this end point occurs in * * @param text text node this end point occurs in */ public void setTextNode(Text textNode) { m_node = textNode; } /** * Set this range end point at the start or end of a text node * * @param text text node this end point starts/end in * @param start whether to make the end point at the start or the end */ public void setTextNode(Text textNode, boolean start) { setTextNode(textNode); setOffset((start || (textNode == null)) ? 0 : textNode.getLength()); } /** * Get the text of this with a "|" at the offset * * @return a string representation of this endpoint */ @Override public String toString() { return getString(false) + "|" + getString(true); } }