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