/**
* 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.editor.selection.html;
import static org.waveprotocol.wave.client.editor.selection.html.JsSelectionIE.Type.None;
import static org.waveprotocol.wave.client.editor.selection.html.JsTextRangeIE.CompareMode.EndToEnd;
import static org.waveprotocol.wave.client.editor.selection.html.JsTextRangeIE.CompareMode.StartToStart;
import static org.waveprotocol.wave.client.editor.selection.html.JsTextRangeIE.MoveUnit.character;
import com.google.gwt.core.client.JavaScriptException;
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.Text;
import com.google.gwt.user.client.ui.RootPanel;
import org.waveprotocol.wave.client.common.util.DomHelper;
import org.waveprotocol.wave.client.editor.selection.html.JsTextRangeIE.CompareMode;
import org.waveprotocol.wave.model.document.util.FocusedPointRange;
import org.waveprotocol.wave.model.document.util.Point;
import org.waveprotocol.wave.model.document.util.PointRange;
/**
* IE-specific selection implementation
*
*
*/
class SelectionImplIE extends SelectionImpl {
/**
* We use a dummy inline element to help us set carets
*/
private final static Element setter = Document.get().createElement("b");
static {
setter.setInnerHTML("x");
}
/**
* The hint is the last seen point.
*/
private Point<Node> hint;
private String savedSelection;
/**
* Clears selection
*/
@Override
void clear() {
JsSelectionIE.get().empty();
}
/**
* {@inheritDoc}
*
* Note(user): IE's selection type reports 'Text' for non-collapsed selections,
* but 'None' for carets as well as for entirely missing selection.
*/
@Override
FocusedPointRange<Node> get() {
PointRange<Node> sel = getOrdered();
// TODO(danilatos): Proper difference between focus and anchor
return sel == null ? null : new FocusedPointRange<Node>(sel.getFirst(), sel.getSecond());
}
@Override
boolean isOrdered() {
// TODO(danilatos): Proper difference between focus and anchor
return true;
}
@Override
PointRange<Node> getOrdered() {
// NOTE(user): try/catch here as JsTextRangeIE.duplicate throws an exception if the
// selection is non-text, i.e. an image. Its much safer to wrap these IE native methods.
// TODO(user): Decide whether returning null is the correct behaviour when exception is
// thrown. If so, remove the logger.error().
try {
// Get selection + corresponding text range
JsSelectionIE selection = JsSelectionIE.get();
JsTextRangeIE start = selection.createRange();
// Best test we have found for empty selection
if (checkNoSelection(selection, start)) {
return null;
}
// create two collapsed ranges, for each end of the selection
JsTextRangeIE end = start.duplicate();
start.collapse(true);
end.collapse(false);
// Translate to HtmlPoints
Point<Node> startPoint = pointAtCollapsedRange(start);
return JsTextRangeIE.equivalent(start, end) ? new PointRange<Node>(startPoint)
: new PointRange<Node>(startPoint, pointAtCollapsedRange(end));
} catch (JavaScriptException e) {
logger.error().log("Cannot get selection", e);
return null;
}
}
private boolean pointMatchesRange(Point<Node> point, JsTextRangeIE target) {
try {
JsTextRangeIE tr = collapsedRangeAtPoint(point);
return tr.compareEndPoints(StartToStart, target) == 0;
} catch (Exception e) {
return false;
}
}
private Point<Node> pointAtCollapsedRange(JsTextRangeIE target) {
if (hint != null && isValid(hint) && pointMatchesRange(hint, target)) {
return hint;
}
hint = pointAtCollapsedRangeInner(target);
return hint;
}
private boolean isValid(Point<Node> p) {
if (p.isInTextNode()) {
return true;
} else {
Node nodeAfter = p.getNodeAfter();
return nodeAfter == null || p.getContainer().equals(p.getNodeAfter().getParentElement());
}
}
/**
* @param target collapsed text range
* @return HtmlPoint matching the collapsed text range
*/
private Point<Node> pointAtCollapsedRangeInner(JsTextRangeIE target) {
// The point is (mostly) either directly in target's parent,
// or in a text node child of parent. (Occasionally, the point
// sits right after the parent, see below.)
Element parent = target.parentElement();
// XXX(zdwang): For some reason IE 7 likes to focus on the input box of the
// contacts pop up during the on key press event when you press shift+enter
// on a blip. This causes attempt.moveToElementText(el) to thrown an
// exception.
// TODO(user): check with zdwang if this is still needed...
if (parent.getTagName().equals("INPUT")) {
return Point.inElement(parent, parent.getFirstChild());
}
// This catches an corner case where the target is actually
// *outside* its parent node. This happens, e.g., in this case:
// <p><thumbnail/>|</p>. Best look out for other such cases!
while (parent.getAttribute("contentEditable").equalsIgnoreCase("false")) {
parent = parent.getParentElement();
}
return binarySearchForRange(target, parent);
// return searchForRangeUsingPaste(target, parent);
// return linearSearchForRange(target, parent);
}
/**
* Search for node by pasting that element at a textRange and locating that
* element directly using getElementById. This is a huge shortcut when there
* are many nodes in parent. However, use with caution as it can fragment text
* nodes.
*
* NOTE(user): The text node fragmentation is a real issue, it causes repairs
* to happen. The constant splitting and repairing can also have performance
* issues that needs to be investigated. We should repair the damage here,
* when its clear how to fix the problem.
*
* @param target
* @param parent
* @return Point
*/
@SuppressWarnings("unused") // NOTE(user): Use later for nodes with many siblings.
private Point<Node> searchForRangeUsingPaste(JsTextRangeIE target, Element parent) {
Element elem = null;
try {
target.pasteHTML("<b id='__paste_target__'>X</b>");
elem = Document.get().getElementById("__paste_target__");
Node nextSibling = elem.getNextSibling();
if (DomHelper.isTextNode(nextSibling)) {
return Point.inText(nextSibling, 0);
} else {
return Point.inElement(parent, nextSibling);
}
} finally {
if (elem != null) {
elem.removeFromParent();
}
}
}
@SuppressWarnings("unused") // NOTE(user): may be used later
private Point<Node> linearSearchForRange(JsTextRangeIE target, Element parent) {
try {
// We'll iterate through the parent's children while moving
// a new collapsed range, attempt, through the points before
// each child.
Node child = parent.getFirstChild();
// Start attempt at beginning of parent
JsTextRangeIE attempt = JsTextRangeIE.create().moveToElementText(parent).collapse(true);
while (child != null) {
// Treat text node children separately
if (DomHelper.isTextNode(child)) {
// Move attempt to end of the text node
int len = child.<Text> cast().getLength();
attempt.move(character, len);
// Test if attempt is now at or past target
if (attempt.compareEndPoints(StartToStart, target) >= 0) {
// Target is in this text node. Compute the offset by creating a new
// text range from target to attempt and measuring the length of the
// text in that range
JsTextRangeIE dup =
attempt.duplicate().setEndPoint(StartToStart, target)
.setEndPoint(EndToEnd, attempt);
return Point.inText(child, len - dup.getText().length());
}
} else {
// Child is an element. Move attempt before child, and test
// if attempt is at or past range
attempt.moveToElementText(child.<Element> cast()).collapse(true);
if (attempt.compareEndPoints(StartToStart, target) >= 0) {
// Return the point before child
return Point.inElement(parent, child);
} else {
// Move attempt past child
// We use our inline, non-empty marker element to do this.
// We also leave it in the dom for max reliability until it's needed
// later, or gets taken out in the finally clause at the end of this method
child.getParentNode().insertAfter(setter, child);
// skip pass the setter.
child = child.getNextSibling();
attempt.moveToElementText(setter).collapse(false);
}
}
// Move to next child
child = child.getNextSibling();
}
// We didn't find target before or in children; return point at end of
// parent
return Point.<Node> end(parent);
// TODO(user): look out for other corner cases
// TODO(user, danilatos): implement danilatos' optimisation of first
// checking inside the last text node that held a point.
// TODO(user): consider binary rather than linear search for target
// TODO(user): does this handle the end of a <p>ab[</p><p>]cd</p> type
// selection?
// TODO(danilatos): When someone selects the "newline" at the edge
// of a <p>, e.g. <p>abc[</p><p>]def</p> (where [ ] is the sel)
// this reports the selection still as <p>abc[]</p><p>def</p>
// It appears to be a detectable scenario when the first line
// is not empty, but it isn't when both lines are empty. However,
// I did notice a difference before when I was experimenting with
// getBookmark(), so perhaps we could resort to tricks with checking
// bookmarks around paragraph boundaries...
// TODO(user, danilatos): consider making attempt and other ranges
// used here static singletons
} finally {
setter.removeFromParent();
}
}
private Point<Node> binarySearchForRange(JsTextRangeIE target, Element parent) {
try {
JsTextRangeIE attempt = JsTextRangeIE.create();
int low = 0;
int high = parent.getChildCount() - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
Node node = parent.getChild(mid);
node.getParentNode().insertBefore(setter, node);
attempt.moveToElementText(setter).collapse(false);
int cmp = attempt.compareEndPoints(CompareMode.StartToEnd, target);
if (cmp == 0) {
if (DomHelper.isTextNode(node)) {
return Point.inText(node, 0);
} else {
return Point.inElement(parent, node);
}
} else if (cmp > 0) {
high = mid - 1;
} else {
if (DomHelper.isTextNode(node)) {
JsTextRangeIE dup = attempt.duplicate();
dup.setEndPoint(EndToEnd, target);
if (dup.getText().length() <= node.<Text> cast().getLength()) {
return Point.inText(node, dup.getText().length());
}
} else {
attempt.moveToElementText(node.<Element> cast()).collapse(false);
if (attempt.compareEndPoints(StartToStart, target) >= 0) {
return Point.inElement(parent, node);
}
}
low = mid + 1;
}
setter.removeFromParent();
}
return Point.<Node> end(parent);
} finally {
setter.removeFromParent();
}
}
/**
* {@inheritDoc}
*/
@Override
void set(Point<Node> startPoint, Point<Node> endPoint) {
JsTextRangeIE start = collapsedRangeAtPoint(startPoint);
JsTextRangeIE end = collapsedRangeAtPoint(endPoint);
// TODO(danilatos): Should be possible to do this more efficiently,
// by separately moving the end point and start point of the range,
// instead of creating 2 collapsed ranges.
JsTextRangeIE.create()
.setEndPoint(StartToStart, start)
.setEndPoint(EndToEnd, end)
.select();
}
/** {@inheritDoc} */
@Override
void set(Point<Node> point) {
collapsedRangeAtPoint(point).select();
}
/**
* @param range
* @return input range collapsed before setter element
*/
private JsTextRangeIE collapseBeforeSetter(JsTextRangeIE range) {
return range
.moveToElementText(setter)
.collapse(true);
}
/**
* @param range
* @param node
* @return input range collapsed before node
*
* Note(user): tempting here to simply do range.moveToElementText(node).collapse(true)
* when node is an element rather than using setter. This does *not* work in some
* cases, though, for example when element is the outer div of an image thumbnail,
* probably because of the contentEditable and unselectable attributes there.
*/
private JsTextRangeIE collapseBeforeNode(JsTextRangeIE range, Node node) {
try {
node.getParentNode().insertBefore(setter, node);
return collapseBeforeSetter(range);
} finally {
setter.removeFromParent();
}
}
/**
* @param range
* @param element
* @return input range collapsed at end of element
*/
private JsTextRangeIE collapseAtEnd(JsTextRangeIE range, Element element) {
try {
element.appendChild(setter);
return collapseBeforeSetter(range);
} finally {
setter.removeFromParent();
}
}
/**
* @param range
* @param element
* @return input range collapsed after element
*/
@SuppressWarnings("unused") // TODO(zdwang): Dead code. Left here, as it may be useful later.
private JsTextRangeIE collapseAfterElement(JsTextRangeIE range, Element element) {
Node next = element.getNextSibling();
return (next != null) ?
collapseBeforeNode(range, next) :
collapseAtEnd(range, element.getParentElement());
}
/**
* Given a node and an offset, returns a collapsed text range at that point.
* @param point
* @return collapsed TextRange
*/
private JsTextRangeIE collapsedRangeAtPoint(Point<Node> point) {
assert point != null && point.getContainer() != null;
JsTextRangeIE range = JsTextRangeIE.create();
if (point.isInTextNode()) {
JsTextRangeIE collapsed = collapseBeforeNode(range, point.getContainer());
collapsed.move(character, point.getTextOffset());
return collapsed;
} else {
Element element = point.getContainer().cast();
Node child = point.getNodeAfter();
return child != null ?
collapseBeforeNode(range, child) :
collapseAtEnd(range, element);
}
}
@Override
boolean selectionExists() {
JsSelectionIE selection = JsSelectionIE.get();
return !checkNoSelection(selection, selection.createRange());
}
/**
* Strange logic that tells us if the selection exists
*
* @param selection
* @param range {@code selection.getRange()} This is an explicit parameter
* to save an object creation, because sometimes we already have this value.
* @return true if there is no selection
*/
private boolean checkNoSelection(JsSelectionIE selection, JsTextRangeIE range) {
// Best test we have found for empty selection
return None.equals(selection.getType())
&& RootPanel.getBodyElement().equals(range.parentElement());
}
@Override
void saveSelection() {
savedSelection = JsSelectionIE.get().createRange().getBookmark();
}
@Override
void restoreSelection() {
JsTextRangeIE range = JsTextRangeIE.create();
range.moveToBookmark(savedSelection);
range.select();
}
}