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