1 /**
  2  * Copyright (C) 2012 KO GmbH <copyright@kogmbh.com>
  3  *
  4  * @licstart
  5  * This file is part of WebODF.
  6  *
  7  * WebODF is free software: you can redistribute it and/or modify it
  8  * under the terms of the GNU Affero General Public License (GNU AGPL)
  9  * as published by the Free Software Foundation, either version 3 of
 10  * the License, or (at your option) any later version.
 11  *
 12  * WebODF is distributed in the hope that it will be useful, but
 13  * WITHOUT ANY WARRANTY; without even the implied warranty of
 14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 15  * GNU Affero General Public License for more details.
 16  *
 17  * You should have received a copy of the GNU Affero General Public License
 18  * along with WebODF.  If not, see <http://www.gnu.org/licenses/>.
 19  * @licend
 20  *
 21  * @source: http://www.webodf.org/
 22  * @source: https://github.com/kogmbh/WebODF/
 23  */
 24 
 25 /*global Node, core, ops, runtime*/
 26 
 27 /**
 28  * @class
 29  * A cursor is a dom node that visually represents a cursor in a DOM tree.
 30  * It should stay synchronized with the selection in the document. When
 31  * there is only one collapsed selection range, a cursor should be shown at
 32  * that point.
 33  *
 34  * Putting the cursor in the DOM tree modifies the DOM, so care should be taken
 35  * to keep the selection consistent. If e.g. a selection is drawn over the
 36  * cursor, and the cursor is updated to the selection, the cursor is removed
 37  * from the DOM because the selection is not collapsed. This means that the
 38  * offsets of the selection may have to be changed.
 39  *
 40  * When the selection is collapsed, the cursor is placed after the point of the
 41  * selection and the selection will stay valid. However, if the cursor was
 42  * placed in the DOM tree and was counted in the offset, the offset in the
 43  * selection should be decreased by one.
 44  *
 45  * Even when the selection allows for a cursor, it might be desireable to hide
 46  * the cursor by not letting it be part of the DOM.
 47  *
 48  * @constructor
 49  * @param {!Document} document  The DOM document in which the cursor is placed
 50  * @param {!string} memberId The memberid this cursor is assigned to
 51  */
 52 core.Cursor = function Cursor(document, memberId) {
 53     "use strict";
 54     var cursorns = 'urn:webodf:names:cursor',
 55         /**@type{!Element}*/
 56         cursorNode = document.createElementNS(cursorns, 'cursor'),
 57         /**@type{!Element}*/
 58         anchorNode = document.createElementNS(cursorns, 'anchor'),
 59         forwardSelection,
 60         recentlyModifiedNodes = [],
 61         /**@type{!Range}*/
 62         selectedRange = /**@type{!Range}*/(document.createRange()),
 63         isCollapsed,
 64         domUtils = core.DomUtils;
 65 
 66     /**
 67      * Split a text node and put the cursor into it.
 68      * @param {!Node} node
 69      * @param {!Text} container
 70      * @param {!number} offset
 71      * @return {undefined}
 72      */
 73     function putIntoTextNode(node, container, offset) {
 74         runtime.assert(Boolean(container), "putCursorIntoTextNode: invalid container");
 75         var parent = container.parentNode;
 76         runtime.assert(Boolean(parent), "putCursorIntoTextNode: container without parent");
 77         runtime.assert(offset >= 0 && offset <= container.length, "putCursorIntoTextNode: offset is out of bounds");
 78 
 79         if (offset === 0) {
 80             parent.insertBefore(node, container);
 81         } else if (offset === container.length) {
 82             parent.insertBefore(node, container.nextSibling);
 83         } else {
 84             container.splitText(offset);
 85             parent.insertBefore(node, container.nextSibling);
 86         }
 87     }
 88     /**
 89      * Remove the cursor from the tree.
 90      * @param {!Element} node
 91      */
 92     function removeNode(node) {
 93         if (node.parentNode) {
 94             recentlyModifiedNodes.push(node.previousSibling);
 95             recentlyModifiedNodes.push(node.nextSibling);
 96             node.parentNode.removeChild(node);
 97         }
 98     }
 99 
100     /**
101      * Put the cursor at a particular position.
102      * @param {!Node} node
103      * @param {!Node} container
104      * @param {!number} offset
105      * @return {undefined}
106      */
107     function putNode(node, container, offset) {
108         if (container.nodeType === Node.TEXT_NODE) {
109             putIntoTextNode(node, /**@type{!Text}*/(container), offset);
110         } else if (container.nodeType === Node.ELEMENT_NODE) {
111             container.insertBefore(node, container.childNodes.item(offset));
112         }
113         recentlyModifiedNodes.push(node.previousSibling);
114         recentlyModifiedNodes.push(node.nextSibling);
115     }
116 
117     /**
118      * Gets the earliest selection node in the document
119      * @return {!Node}
120      */
121     function getStartNode() {
122         return forwardSelection ? anchorNode : cursorNode;
123     }
124 
125     /**
126      * Gets the latest selection node in the document
127      * @return {!Node}
128      */
129     function getEndNode() {
130         return forwardSelection ? cursorNode : anchorNode;
131     }
132     /**
133      * Obtain the node representing the cursor. This is
134      * the selection end point
135      * @return {!Element}
136      */
137     this.getNode = function () {
138         return cursorNode;
139     };
140     /**
141      * Obtain the node representing the selection start point.
142      * If a 0-length range is selected (e.g., by clicking without
143      * dragging),, this will return the exact same node as getNode
144      * @return {!Element}
145      */
146     this.getAnchorNode = function () {
147         return anchorNode.parentNode ? anchorNode : cursorNode;
148     };
149     /**
150      * Obtain the selection to which the cursor corresponds.
151      * @return {!Range}
152      */
153     this.getSelectedRange = function () {
154         if (isCollapsed) {
155             selectedRange.setStartBefore(cursorNode);
156             selectedRange.collapse(true);
157         } else {
158             selectedRange.setStartAfter(getStartNode());
159             selectedRange.setEndBefore(getEndNode());
160         }
161         return selectedRange;
162     };
163     /**
164      * Synchronize the cursor to a specific range
165      * If there is a single collapsed selection range, the cursor will be placed
166      * there. If not, the cursor will be removed from the document tree.
167      * @param {!Range} range
168      * @param {boolean=} isForwardSelection Set to true to indicate the direction of the
169      *                          range is startContainer => endContainer. This should be false if
170      *                          the user creates a selection that ends before it starts in the document (i.e.,
171      *                          drags the range backwards from the start point)
172      * @return {undefined}
173      */
174     this.setSelectedRange = function (range, isForwardSelection) {
175         if (selectedRange && selectedRange !== range) {
176             selectedRange.detach();
177         }
178         selectedRange = range;
179         forwardSelection = isForwardSelection !== false;
180         isCollapsed = range.collapsed;
181 
182         // TODO the nodes need to be added and removed in the right order to preserve the range
183         if (range.collapsed) {
184             removeNode(anchorNode);
185             removeNode(cursorNode);
186             putNode(cursorNode, /**@type {!Node}*/(range.startContainer), range.startOffset);
187         } else {
188             removeNode(anchorNode);
189             removeNode(cursorNode);
190             // putting in the end node first eliminates the chance the position of the start node is destroyed
191             putNode(getEndNode(), /**@type {!Node}*/(range.endContainer), range.endOffset);
192             putNode(getStartNode(), /**@type {!Node}*/(range.startContainer), range.startOffset);
193         }
194         recentlyModifiedNodes.forEach(domUtils.normalizeTextNodes);
195         recentlyModifiedNodes.length = 0;
196     };
197     /**
198      * Returns if the selection of this cursor has the
199      * same direction as the direction of the range
200      * @return {boolean}
201      */
202     this.hasForwardSelection = function () {
203         return forwardSelection;
204     };
205     /**
206      * Remove the cursor from the document tree.
207      * @return {undefined}
208      */
209     this.remove = function () {
210         removeNode(cursorNode);
211         recentlyModifiedNodes.forEach(domUtils.normalizeTextNodes);
212         recentlyModifiedNodes.length = 0;
213     };
214 
215     function init() {
216         // mark cursornode with memberid
217         cursorNode.setAttributeNS(cursorns, "memberId", memberId);
218         anchorNode.setAttributeNS(cursorns, "memberId", memberId);
219     }
220 
221     init();
222 };
223