1 /**
  2  * Copyright (C) 2012-2013 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 runtime, core, ops, odf*/
 26 
 27 (function () {
 28     "use strict";
 29     var
 30         /**
 31          * @const
 32          * @type {!number}
 33          */
 34         PREVIOUS_STEP = 0,
 35         /**
 36          * @const
 37          * @type {!number}
 38          */
 39         NEXT_STEP = 1;
 40 
 41     /**
 42      *
 43      * @constructor
 44      * @param {!function():!Element} getRootNode
 45      * @param {!function(!Node):!core.PositionIterator} newIterator
 46      * @param {!core.PositionFilter} filter
 47      * @param {!number} bucketSize  Minimum number of steps between cache points
 48      */
 49     ops.OdtStepsTranslator = function OdtStepsTranslator(getRootNode, newIterator, filter, bucketSize) {
 50         var rootNode,
 51             /**@type{!ops.StepsCache}*/
 52             stepsCache,
 53             odfUtils = new odf.OdfUtils(),
 54             domUtils = new core.DomUtils(),
 55             /**@type{!core.PositionIterator}*/
 56             iterator,
 57             /**@const*/
 58             FILTER_ACCEPT = core.PositionFilter.FilterResult.FILTER_ACCEPT;
 59 
 60         /**
 61          * Update the steps cache based on the current iterator position. This can either add new
 62          * bookmarks or update existing references and repair damaged regions of the cache.
 63          *
 64          * @param {!number} steps
 65          * @param {!core.PositionIterator} iterator
 66          * @param {!boolean} isStep
 67          * @return {undefined}
 68          */
 69         function updateCache(steps, iterator, isStep) {
 70             var node = iterator.getCurrentNode();
 71 
 72             if (iterator.isBeforeNode() && odfUtils.isParagraph(node)) {
 73                 if (!isStep) {
 74                     // Paragraph bookmarks indicate "first position in the paragraph"
 75                     // If the current stable point is before the first walkable position (as often happens)
 76                     // simply increase the step number by 1 to move to within the paragraph node
 77                     steps += 1;
 78                 }
 79                 stepsCache.updateBookmark(steps, node);
 80             }
 81         }
 82 
 83         /**
 84          * Saved bookmarks always represent the first step inside the corresponding paragraph or node. Based on the
 85          * current TextPositionFilter impl, this means rounding up if the current iterator position is not on a step.
 86          * @param {!number} steps
 87          * @param {!core.PositionIterator} iterator
 88          * @return {undefined}
 89          */
 90         function roundUpToStep(steps, iterator) {
 91             do {
 92                 if (filter.acceptPosition(iterator) === FILTER_ACCEPT) {
 93                     // Have reached the step represented by the paragraph bookmark
 94                     updateCache(steps, iterator, true);
 95                     break;
 96                 }
 97                 // This logic inverts the +1 logic in updateCache. Conceptually speaking, the stored
 98                 // bookmark represents the first step in the paragraph. Until the first step is found,
 99                 // the iterator is still technically on steps-1.
100                 updateCache(steps - 1, iterator, false);
101             } while (iterator.nextPosition());
102         }
103 
104         /**
105          * This evil little check is necessary because someone, not mentioning any names *cough*
106          * added an extremely hacky undo manager that replaces the root node in order to go back
107          * to a prior document state.
108          * This makes things very sad, and kills baby kittens.
109          * Unfortunately, no-one has had time yet to write a *real* undo stack... so we just need
110          * to cope with it for now.
111          * @return {undefined}
112          */
113         function verifyRootNode() {
114             // TODO Remove when a proper undo manager arrives
115             var currentRootNode = getRootNode();
116             if (currentRootNode !== rootNode) {
117                 if (rootNode) {
118                     // verifyRootNode is called during init. Don't log misleading messages in this case
119                     runtime.log("Undo detected. Resetting steps cache");
120                 }
121                 rootNode = currentRootNode;
122                 stepsCache = new ops.StepsCache(rootNode, bucketSize, roundUpToStep);
123                 iterator = newIterator(rootNode);
124             }
125         }
126 
127         /**
128          * Convert the requested steps from root into the equivalent DOM node & offset pair. If the
129          * requested step is before the start or past the end of the document, a RangeError will be thrown.
130          * @param {!number} steps
131          * @return {!{node: !Node, offset: !number}}
132          */
133         this.convertStepsToDomPoint = function (steps) {
134             var /**@type{!number}*/
135                 stepsFromRoot,
136                 isStep;
137 
138             if (isNaN(steps)) {
139                 throw new TypeError("Requested steps is not numeric (" + steps + ")");
140             }
141             if (steps < 0) {
142                 throw new RangeError("Requested steps is negative (" + steps + ")");
143             }
144             verifyRootNode();
145             stepsFromRoot = stepsCache.setToClosestStep(steps, iterator);
146 
147             while (stepsFromRoot < steps && iterator.nextPosition()) {
148                 isStep = filter.acceptPosition(iterator) === FILTER_ACCEPT;
149                 if (isStep) {
150                     stepsFromRoot += 1;
151                 }
152                 updateCache(stepsFromRoot, iterator, isStep);
153             }
154             if (stepsFromRoot !== steps) {
155                 throw new RangeError("Requested steps (" + steps + ") exceeds available steps (" + stepsFromRoot + ")");
156             }
157             return {
158                 node: iterator.container(),
159                 offset: iterator.unfilteredDomOffset()
160             };
161         };
162 
163         /**
164          * Uses the provided delegate to choose between rounding up or rounding down to the nearest step.
165          * @param {!core.PositionIterator} iterator
166          * @param {function(!number, !Node, !number):boolean=} roundDirection
167          * @return {!boolean} Returns true if an accepted position is found, otherwise returns false.
168          */
169         function roundToPreferredStep(iterator, roundDirection) {
170             if (!roundDirection || filter.acceptPosition(iterator) === FILTER_ACCEPT) {
171                 return true;
172             }
173 
174             while (iterator.previousPosition()) {
175                 if (filter.acceptPosition(iterator) === FILTER_ACCEPT) {
176                     if (roundDirection(PREVIOUS_STEP, iterator.container(), iterator.unfilteredDomOffset())) {
177                         return true;
178                     }
179                     break;
180                 }
181             }
182 
183             while (iterator.nextPosition()) {
184                 if (filter.acceptPosition(iterator) === FILTER_ACCEPT) {
185                     if (roundDirection(NEXT_STEP, iterator.container(), iterator.unfilteredDomOffset())) {
186                         return true;
187                     }
188                     break;
189                 }
190             }
191 
192             return false;
193         }
194 
195         /**
196          * Convert the supplied DOM node & offset pair into it's equivalent steps from root
197          * If the node & offset is not in an accepted location, the
198          * roundDirection delegate is used to choose between rounding up or
199          * rounding down to the nearest step. If not provided, the default
200          * behaviour is to round down.
201          * @param {!Node} node
202          * @param {!number} offset
203          * @param {function(!number, !Node, !number):!boolean=} roundDirection
204          * @return {!number}
205          */
206         this.convertDomPointToSteps = function (node, offset, roundDirection) {
207             var stepsFromRoot,
208                 beforeRoot,
209                 destinationNode,
210                 destinationOffset,
211                 rounding = 0,
212                 isStep;
213 
214             verifyRootNode();
215             if (!domUtils.containsNode(rootNode, node)) {
216                 beforeRoot = domUtils.comparePoints(rootNode, 0, node, offset) < 0;
217                 node = /**@type{!Node}*/(rootNode);
218                 offset = beforeRoot ? 0 : /**@type{!Element}*/(rootNode).childNodes.length;
219             }
220 
221             iterator.setUnfilteredPosition(node, offset);
222             // if the user has set provided a rounding selection delegate, use that to select the previous or next
223             // step if the (node, offset) position is not accepted by the filter
224             if (!roundToPreferredStep(iterator, roundDirection)) {
225                 // The rounding selection delegate rejected both. Revert back to the previous step
226                 iterator.setUnfilteredPosition(node, offset);
227             }
228 
229             // Get the iterator equivalent position of the current node & offset
230             // This ensures the while loop will match the exact container and offset during iteration
231             destinationNode = iterator.container();
232             destinationOffset = iterator.unfilteredDomOffset();
233 
234             stepsFromRoot = stepsCache.setToClosestDomPoint(destinationNode, destinationOffset, iterator);
235             if (domUtils.comparePoints(iterator.container(), iterator.unfilteredDomOffset(), destinationNode, destinationOffset) < 0) {
236                 // Special case: the requested DOM point is between the bookmark node and walkable step it represents
237                 return stepsFromRoot > 0 ? stepsFromRoot - 1 : stepsFromRoot;
238             }
239 
240             while (!(iterator.container() === destinationNode && iterator.unfilteredDomOffset() === destinationOffset)
241                     && iterator.nextPosition()) {
242                 isStep = filter.acceptPosition(iterator) === FILTER_ACCEPT;
243                 if (isStep) {
244                     stepsFromRoot += 1;
245                 }
246                 updateCache(stepsFromRoot, iterator, isStep);
247             }
248             return stepsFromRoot + rounding;
249         };
250 
251         /**
252          * Iterates over all available positions starting at the root node and primes the cache
253          * @return {undefined}
254          */
255         this.prime = function () {
256             var stepsFromRoot,
257                 isStep;
258 
259             verifyRootNode();
260             stepsFromRoot = stepsCache.setToClosestStep(0, iterator);
261             while (iterator.nextPosition()) {
262                 isStep = filter.acceptPosition(iterator) === FILTER_ACCEPT;
263                 if (isStep) {
264                     stepsFromRoot += 1;
265                 }
266                 updateCache(stepsFromRoot, iterator, isStep);
267             }
268         };
269 
270         /**
271          * @param {!{position: !number}} eventArgs
272          * @return {undefined}
273          */
274         this.handleStepsInserted = function (eventArgs) {
275             verifyRootNode();
276             // Old position = position
277             // New position = position + length
278             // E.g., {position: 10, length: 1} indicates 10 => 10, New => 11, 11 => 12, 12 => 13
279             stepsCache.damageCacheAfterStep(eventArgs.position);
280         };
281 
282         /**
283          * @param {!{position: !number}} eventArgs
284          * @return {undefined}
285          */
286         this.handleStepsRemoved = function (eventArgs) {
287             verifyRootNode();
288             // Old position = position + length
289             // New position = position
290             // E.g., {position: 10, length: 1} indicates 10 => 10, 11 => 10, 12 => 11
291 
292             // TODO OpRemoveText inaccurately reports the position making it necessary subtract 1
293             // Paragraph merge behaviours might result in the paragraph exactly at the reported position being
294             // replaced by a later paragraph. Conceptually, this means the last unmodified position is
295             // actually 1 step prior to the replace paragraph.
296             stepsCache.damageCacheAfterStep(eventArgs.position - 1);
297         };
298 
299         function init() {
300             verifyRootNode();
301         }
302         init();
303     };
304 
305     /**
306      * @const
307      * @type {!number}
308      */
309     ops.OdtStepsTranslator.PREVIOUS_STEP = PREVIOUS_STEP;
310 
311     /**
312      * @const
313      * @type {!number}
314      */
315     ops.OdtStepsTranslator.NEXT_STEP = NEXT_STEP;
316 }());
317