/* * See the NOTICE file distributed with this work for additional * information regarding copyright ownership. * * This is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as * published by the Free Software Foundation; either version 2.1 of * the License, or (at your option) any later version. * * This software is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this software; if not, write to the Free * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA * 02110-1301 USA, or see the FSF site: http://www.fsf.org. */ package org.xwiki.gwt.dom.client.internal.ie; import org.xwiki.gwt.dom.client.DOMUtils; import org.xwiki.gwt.dom.client.Element; import org.xwiki.gwt.dom.client.Range; import org.xwiki.gwt.dom.client.RangeCompare; import org.xwiki.gwt.dom.client.internal.AbstractSelection; import org.xwiki.gwt.dom.client.internal.ie.TextRange.Unit; import com.google.gwt.dom.client.ImageElement; import com.google.gwt.dom.client.Node; /** * The implementation of Mozilla's selection specification using Internet Explorer's old selection API. * * @version $Id: 1b592866ec2bcd68d7509e3e14a6e80f7a4f9ce4 $ */ public class IEOldSelection extends AbstractSelection { /** * Specifies where a range starts or ends inside the DOM tree. */ protected static final class RangeBoundary { /** * The node containing the range boundary. */ private final Node container; /** * The offset within the {@link #container} where the boundary is placed. */ private final int offset; /** * Creates a new range boundary. * * @param container {@link #container} * @param offset {@link #offset} */ public RangeBoundary(Node container, int offset) { this.container = container; this.offset = offset; } /** * @return {@link #container} */ public Node getContainer() { return container; } /** * @return {@link #offset} */ public int getOffset() { return offset; } } /** * The underlying native selection object provided by the browser. */ private final NativeSelection nativeSelection; /** * The element used to mark the start of the selection. * * @see #addRange(Range) */ private final Element startMarker; /** * The native text range used to set the start of the selection. * * @see TextRange#setEndPoint(RangeCompare, TextRange) * @see #addRange(Range) */ private final TextRange startRef; /** * The element used to mark the end of the selection. * * @see #addRange(Range) */ private final Element endMarker; /** * The native text range used to set the end of the selection. * * @see TextRange#setEndPoint(RangeCompare, TextRange) * @see #addRange(Range) */ private final TextRange endRef; /** * Creates a new instance that wraps the given native selection object. This object will be used to implement * Mozilla's selection specification. * * @param nativeSelection the underlying native selection object to be used */ public IEOldSelection(NativeSelection nativeSelection) { this.nativeSelection = nativeSelection; startMarker = createBoundaryMarker(); startRef = TextRange.newInstance(nativeSelection.getOwnerDocument()); endMarker = (Element) startMarker.cloneNode(true); endRef = startRef.duplicate(); } /** * @return {@link #nativeSelection} */ protected NativeSelection getNativeSelection() { return nativeSelection; } @Override public void addRange(Range range) { DOMUtils.getInstance().scrollIntoView(range); if (range.getStartContainer() == range.getEndContainer() && range.getStartContainer().getNodeType() == Node.ELEMENT_NODE && range.getStartOffset() == range.getEndOffset() - 1) { // The given range wraps a DOM node. Node selectedNode = range.getStartContainer().getChildNodes().getItem(range.getStartOffset()); // Try to make a control selection. try { ControlRange controlRange = ControlRange.newInstance(nativeSelection.getOwnerDocument()); controlRange.add((Element) selectedNode); controlRange.select(); return; } catch (Exception e) { // The selected node doesn't support control selection. } } // Otherwise use text selection. addTextRange(adjustRangeAndDOM(range)); } /** * Adjusts the given range and the DOM tree prior to selecting the range. We have to do this because IE selection * boundaries cannot, in most of the cases, be placed inside empty elements or empty text nodes or at the beginning * of a text node. * * @param range the range to be adjusted * @return the adjusted range */ private Range adjustRangeAndDOM(Range range) { Node start = range.getStartContainer(); if (range.isCollapsed() && start.getNodeType() == Node.TEXT_NODE && range.getStartOffset() == 0 && !isTextBefore(start)) { start.setNodeValue("\u00A0" + start.getNodeValue()); Range adjusted = range.cloneRange(); adjusted.setEnd(start, 1); return adjusted; } return range; } /** * Utility method for testing if the previous sibling (skipping empty text nodes) of a DOM node is a non-empty text * node. * * @param node a DOM node * @return true if the previous sibling, skipping empty text nodes, of the given node is a non-empty text node */ private boolean isTextBefore(Node node) { Node sibling = node.getPreviousSibling(); // Skip empty text nodes. while (sibling != null && sibling.getNodeType() == Node.TEXT_NODE && sibling.getNodeValue().length() == 0) { sibling = sibling.getPreviousSibling(); } return sibling != null && sibling.getNodeType() == Node.TEXT_NODE; } /** * Creates a text selection from the given range. * * @param range the range to be added to the selection */ protected void addTextRange(Range range) { DOMUtils domUtils = DOMUtils.getInstance(); int leftOffset = 0; int rightOffset = domUtils.getLength(range.getEndContainer()) - range.getEndOffset(); switch (range.getStartContainer().getNodeType()) { case Node.TEXT_NODE: leftOffset = range.getStartOffset(); // fall through case DOMUtils.CDATA_NODE: case DOMUtils.COMMENT_NODE: range.getStartContainer().getParentNode().insertBefore(startMarker, range.getStartContainer()); break; case Node.ELEMENT_NODE: domUtils.insertAt(range.getStartContainer(), startMarker, range.getStartOffset()); break; default: throw new IllegalArgumentException(); } startRef.moveToElementText(startMarker); startRef.moveEnd(Unit.CHARACTER, leftOffset); switch (range.getEndContainer().getNodeType()) { case DOMUtils.CDATA_NODE: case DOMUtils.COMMENT_NODE: rightOffset = 0; // fall through case Node.TEXT_NODE: domUtils.insertAfter(endMarker, range.getEndContainer()); break; case Node.ELEMENT_NODE: domUtils.insertAt(range.getEndContainer(), endMarker, range.getEndContainer().getChildNodes() .getLength() - rightOffset); rightOffset = 0; break; default: throw new IllegalArgumentException(); } endRef.moveToElementText(endMarker); endRef.moveStart(Unit.CHARACTER, -rightOffset); TextRange textRange = TextRange.newInstance(nativeSelection.getOwnerDocument()); textRange.setEndPoint(RangeCompare.END_TO_START, startRef); textRange.setEndPoint(RangeCompare.START_TO_END, endRef); domUtils.detach(startMarker); domUtils.detach(endMarker); textRange.select(); } /** * @return an element that can be used a range boundary marker for this selection */ private Element createBoundaryMarker() { ImageElement marker = nativeSelection.getOwnerDocument().createImageElement(); marker.setWidth(0); marker.setHeight(0); return marker.cast(); } @Override public Range getRangeAt(int index) { if (index != 0) { throw new IndexOutOfBoundsException(); } NativeRange nativeRange = nativeSelection.createRange(); Range range = nativeRange.getOwnerDocument().createRange(); if (nativeRange.isTextRange()) { TextRange textRange = (TextRange) nativeRange; RangeBoundary start = getBoundary(textRange, true); range.setStart(start.getContainer(), start.getOffset()); RangeBoundary end = getBoundary(textRange, false); range.setEnd(end.getContainer(), end.getOffset()); } else { range.selectNode(((ControlRange) nativeRange).get(0)); } return range; } @Override public int getRangeCount() { return 1; } @Override public void removeAllRanges() { getNativeSelection().empty(); } @Override public void removeRange(Range range) { throw new UnsupportedOperationException(); } /** * Computes the start or end container of a text range. * * @param textRange the text range for which to compute the boundary container * @param start specifies which boundary container to compute * @return the container of the range's start , if start is true, or the container of the range's end, otherwise */ protected RangeBoundary getBoundary(TextRange textRange, boolean start) { // Determine the element containing the specified range boundary. TextRange refRange = textRange.duplicate(); refRange.collapse(start); Node container = refRange.getParentElement(); // We use another text range to find the boundary position within its element container. TextRange searchRange = TextRange.newInstance(textRange.getOwnerDocument()); // Initially the boundary can be anywhere inside its element container. searchRange.moveToElementText(Element.as(container)); // The object used to compare the start point of the search range with the searched boundary. RangeCompare compareStart = RangeCompare.valueOf(start, true); // Iterate through all child nodes in search for the range boundary. Node child = container.getFirstChild(); int offset = 0; while (child != null) { if (child.getNodeType() == Node.ELEMENT_NODE) { // Select the element. refRange.moveToElementText(Element.as(child)); // Reduce the search range by moving its start point after the current element. searchRange.setEndPoint(RangeCompare.END_TO_START, refRange); if (searchRange.compareEndPoints(compareStart, textRange) > 0) { break; } } else if (child.getNodeType() == Node.TEXT_NODE && child.getNodeValue().length() > 0) { // Select the text node. Using TextRange#findText is so far the most reliable way of doing this. Moving // the start point of the search range with the number of characters in the text node doesn't always // jump over the text node because the search range can have caret positions left over before the text // node (e.g. when moving from one table cell to the next you have to jump over the border, which is // counted by TextRange#move as a character). refRange = searchRange.duplicate(); // We have to convert nbsp's to plain spaces because TextRange#getText seems to do so. if (!refRange.findText(child.getNodeValue().replace('\u00A0', '\u0020'), 0, 4)) { // We shouldn't get here! throw new RuntimeException("Unexpected behavior of TextRange#findText"); } // Reduce the search range by moving its start point after the current text node. searchRange.setEndPoint(RangeCompare.END_TO_START, refRange); if (searchRange.compareEndPoints(compareStart, textRange) >= 0) { container = child; // Now we have to compute the offset within this text node. searchRange = textRange.duplicate(); searchRange.collapse(start); offset = getOffset(searchRange, refRange); break; } } child = child.getNextSibling(); offset++; } return new RangeBoundary(container, offset); } /** * Binary search the position of the given caret inside the specified text. * * @param caret a text range collapsed inside a text node * @param text a text range selecting a text node * @return the offset of the given caret inside the text selected by the second range */ private int getOffset(TextRange caret, TextRange text) { int start = 0; int end = text.getText().length(); TextRange finder = TextRange.newInstance(caret.getOwnerDocument()); while (start < end) { int middle = (start + end) / 2; finder.setEndPoint(RangeCompare.START_TO_START, text); finder.move(Unit.CHARACTER, middle); int delta = caret.compareEndPoints(RangeCompare.START_TO_START, finder); if (delta == 0) { return middle; } else if (delta < 0) { end = middle; } else { start = middle + 1; } } return start; } }