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