1 /** 2 * Copyright (C) 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 gui,ops,core,runtime*/ 26 27 /** 28 * @param {gui.UndoStateRules=} defaultRules 29 * @constructor 30 * @implements gui.UndoManager 31 */ 32 gui.TrivialUndoManager = function TrivialUndoManager(defaultRules) { 33 "use strict"; 34 35 var self = this, 36 cursorns = 'urn:webodf:names:cursor', 37 domUtils = new core.DomUtils(), 38 /**@type{?Element}*/ 39 initialDoc, 40 /**@type{!Array.<!ops.Operation>}*/ 41 initialState = [], 42 playFunc, 43 /**@type{!ops.Document}*/ 44 document, 45 /**@type{!Array.<!ops.Operation>}*/ 46 currentUndoState = [], 47 /**@type{!Array.<!Array.<!ops.Operation>>}*/ 48 undoStates = [], 49 /**@type{!Array.<!Array.<!ops.Operation>>}*/ 50 redoStates = [], 51 eventNotifier = new core.EventNotifier([ 52 gui.UndoManager.signalUndoStackChanged, 53 gui.UndoManager.signalUndoStateCreated, 54 gui.UndoManager.signalUndoStateModified, 55 gui.TrivialUndoManager.signalDocumentRootReplaced 56 ]), 57 undoRules = defaultRules || new gui.UndoStateRules(), 58 isExecutingOps = false; 59 60 /** 61 * Execute all operations in the supplied array 62 * @param {!Array.<!ops.Operation>} operations 63 * @return {undefined} 64 */ 65 function executeOperations(operations) { 66 if (operations.length > 0) { 67 isExecutingOps = true; // Used to ignore operations received whilst performing an undo or redo 68 playFunc(operations); 69 isExecutingOps = false; 70 } 71 } 72 73 function emitStackChange() { 74 eventNotifier.emit(gui.UndoManager.signalUndoStackChanged, { 75 undoAvailable: self.hasUndoStates(), 76 redoAvailable: self.hasRedoStates() 77 }); 78 } 79 80 /** 81 * @return {!Array.<!ops.Operation>} 82 */ 83 function mostRecentUndoState() { 84 return undoStates[undoStates.length - 1]; 85 } 86 87 /** 88 * Pushes the currentUndoState into the undoStates if necessary 89 */ 90 function completeCurrentUndoState() { 91 if (currentUndoState !== initialState // Initial state should never be in the undo stack 92 && currentUndoState !== mostRecentUndoState()) { 93 // undoStates may already contain the current undo state if the user 94 // has moved backwards and then forwards in the undo stack 95 undoStates.push(currentUndoState); 96 } 97 } 98 99 /** 100 * @param {!Node} node 101 */ 102 function removeNode(node) { 103 var sibling = node.previousSibling || node.nextSibling; 104 node.parentNode.removeChild(node); 105 domUtils.normalizeTextNodes(sibling); 106 } 107 108 /** 109 * @param {!Element} root 110 */ 111 function removeCursors(root) { 112 domUtils.getElementsByTagNameNS(root, cursorns, "cursor").forEach(removeNode); 113 domUtils.getElementsByTagNameNS(root, cursorns, "anchor").forEach(removeNode); 114 } 115 116 /** 117 * Converts an object hash into an unordered array of its values 118 * @param {!Object} obj 119 * @return {!Array.<Object>} 120 */ 121 function values(obj) { 122 return Object.keys(obj).map(function (key) { return obj[key]; }); 123 } 124 125 /** 126 * Reduce the provided undo states to just unique AddCursor followed by 127 * MoveCursor commands for each still-present cursor. This is used when 128 * restoring the original document state at the start of an undo step 129 * @param {!Array.<Array.<!ops.Operation>>} undoStates 130 * @return {!Array.<!ops.Operation>} 131 */ 132 function extractCursorStates(undoStates) { 133 var addCursor = {}, 134 moveCursor = {}, 135 requiredAddOps = {}, 136 remainingAddOps, 137 operations = undoStates.pop(); 138 139 document.getMemberIds().forEach(function (memberid) { 140 requiredAddOps[memberid] = true; 141 }); 142 remainingAddOps = Object.keys(requiredAddOps).length; 143 144 // Every cursor that is visible on the document will need to be restored 145 // Only need the *last* move or add operation for each visible cursor, as the length & position 146 // are absolute 147 /** 148 * @param {!ops.Operation} op 149 */ 150 function processOp(op) { 151 var spec = op.spec(); 152 if (!requiredAddOps[spec.memberid]) { 153 return; 154 } 155 switch (spec.optype) { 156 case "AddCursor": 157 if (!addCursor[spec.memberid]) { 158 addCursor[spec.memberid] = op; 159 delete requiredAddOps[spec.memberid]; 160 remainingAddOps -= 1; 161 } 162 break; 163 case "MoveCursor": 164 if (!moveCursor[spec.memberid]) { 165 moveCursor[spec.memberid] = op; 166 } 167 break; 168 } 169 } 170 171 while (operations && remainingAddOps > 0) { 172 operations.reverse(); // Want the LAST move/add operation seen 173 operations.forEach(processOp); 174 operations = undoStates.pop(); 175 } 176 177 return values(addCursor).concat(values(moveCursor)); 178 } 179 180 /** 181 * Subscribe to events related to the undo manager 182 * @param {!string} signal 183 * @param {!Function} callback 184 */ 185 this.subscribe = function (signal, callback) { 186 eventNotifier.subscribe(signal, callback); 187 }; 188 189 /** 190 * Unsubscribe to events related to the undo manager 191 * @param {!string} signal 192 * @param {!Function} callback 193 */ 194 this.unsubscribe = function (signal, callback) { 195 eventNotifier.unsubscribe(signal, callback); 196 }; 197 198 /** 199 * Returns true if there are one or more undo states available 200 * @return {boolean} 201 */ 202 this.hasUndoStates = function () { 203 return undoStates.length > 0; 204 }; 205 206 /** 207 * Returns true if there are one or more redo states available 208 * @return {boolean} 209 */ 210 this.hasRedoStates = function () { 211 return redoStates.length > 0; 212 }; 213 214 /** 215 * Set the OdtDocument to operate on 216 * @param {!ops.Document} newDocument 217 */ 218 this.setDocument = function (newDocument) { 219 document = newDocument; 220 }; 221 222 /** 223 * @inheritDoc 224 */ 225 this.purgeInitialState = function () { 226 undoStates.length = 0; 227 redoStates.length = 0; 228 initialState.length = 0; 229 currentUndoState.length = 0; 230 initialDoc = null; 231 emitStackChange(); 232 }; 233 234 function setInitialState() { 235 initialDoc = document.cloneDocumentElement(); 236 // The current state may contain cursors if the initial state is modified whilst the document is in edit mode. 237 // To prevent this issue, immediately purge all cursor nodes after cloning 238 removeCursors(initialDoc); 239 completeCurrentUndoState(); 240 // We just threw away the cursors in the snapshot, so need to recover all these operations so the 241 // cursor can be re-inserted when an undo is performed 242 currentUndoState = initialState = extractCursorStates([initialState].concat(undoStates)); 243 undoStates.length = 0; 244 redoStates.length = 0; 245 emitStackChange(); 246 } 247 248 /** 249 * @inheritDoc 250 */ 251 this.setInitialState = setInitialState; 252 253 /** 254 * @inheritDoc 255 */ 256 this.initialize = function () { 257 if (!initialDoc) { 258 setInitialState(); 259 } 260 }; 261 262 /** 263 * Sets the playback function to use to re-execute operations from the undo stack. 264 * @param {!function(!Array.<!ops.Operation>)} playback_func 265 */ 266 this.setPlaybackFunction = function (playback_func) { 267 playFunc = playback_func; 268 }; 269 270 /** 271 * Track the execution of an operation, and add it to the available undo states 272 * @param {!ops.Operation} op 273 * @return {undefined} 274 */ 275 this.onOperationExecuted = function (op) { 276 if (isExecutingOps) { 277 return; // Ignore new operations generated whilst performing an undo/redo 278 } 279 280 // An edit operation is assumed to indicate the end of the initial state. The user can manually 281 // reset the initial state later with setInitialState if desired. 282 // Additionally, an edit operation received when in the middle of the undo stack should also create a new state, 283 // as the current undo state is effectively "sealed" and shouldn't gain additional document modifications. 284 if ((undoRules.isEditOperation(op) && (currentUndoState === initialState || redoStates.length > 0)) 285 || !undoRules.isPartOfOperationSet(op, currentUndoState)) { 286 redoStates.length = 0; // Creating a new undo state should always reset the redo stack 287 completeCurrentUndoState(); 288 currentUndoState = [op]; 289 // Every undo state *MUST* contain an edit for it to be valid for undo or redo 290 undoStates.push(currentUndoState); 291 eventNotifier.emit(gui.UndoManager.signalUndoStateCreated, { operations: currentUndoState }); 292 emitStackChange(); 293 } else { 294 currentUndoState.push(op); 295 eventNotifier.emit(gui.UndoManager.signalUndoStateModified, { operations: currentUndoState }); 296 } 297 }; 298 299 /** 300 * Move forward the desired number of states. Will stop when the number of 301 * states is reached, or no more redo states are available. 302 * @param {!number} states 303 * @return {!number} Returns the number of states actually moved 304 */ 305 this.moveForward = function (states) { 306 var moved = 0, 307 redoOperations; 308 309 while (states && redoStates.length) { 310 redoOperations = redoStates.pop(); 311 undoStates.push(redoOperations); 312 executeOperations(redoOperations); 313 states -= 1; 314 moved += 1; 315 } 316 317 if (moved) { 318 // There is at least one undo stack now available due to the move forward 319 // Reset the most recent undo state to receive new (non-edit) commands again 320 currentUndoState = mostRecentUndoState(); 321 // Only report the stack has modified if moveForward actually did something 322 emitStackChange(); 323 } 324 return moved; 325 }; 326 327 /** 328 * Move backward the desired number of states. Will stop when the number of 329 * states is reached, or no more undo states are available. 330 * @param {!number} states 331 * @return {!number} Returns the number of states actually moved 332 */ 333 this.moveBackward = function (states) { 334 var moved = 0; 335 336 while (states && undoStates.length) { 337 redoStates.push(undoStates.pop()); 338 states -= 1; 339 moved += 1; 340 } 341 342 if (moved) { 343 // Need to reset the odt document cursor list back to nil so new cursors are correctly re-registered 344 document.getMemberIds().forEach(function (memberid) { 345 document.removeCursor(memberid); 346 }); 347 // Only do actual work if moveBackward does something to the undo stacks 348 document.setDocumentElement(/**@type{!Element}*/(initialDoc.cloneNode(true))); 349 eventNotifier.emit(gui.TrivialUndoManager.signalDocumentRootReplaced, { }); 350 executeOperations(initialState); 351 undoStates.forEach(executeOperations); 352 353 // On a move back command, new ops should be subsequently 354 // evaluated for inclusion in the initial state again. This will 355 // collect other cursor movement events and store them. 356 // Without this step, an undo will always reset cursor position 357 // back to the start of the document 358 currentUndoState = mostRecentUndoState() || initialState; 359 emitStackChange(); 360 } 361 return moved; 362 }; 363 }; 364 365 /**@const*/ gui.TrivialUndoManager.signalDocumentRootReplaced = "documentRootReplaced"; 366