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 (function() { 28 "use strict"; 29 30 var/** 31 * Base for generating unique state ids 32 * @type {!number} 33 */ 34 stateIdBase = 0; 35 36 /** 37 * Id for a document state in the Undo/Redo history 38 * @constructor 39 * @param {!number=} mainId 40 * @param {!number=} subId 41 */ 42 function StateId(mainId, subId) { 43 /**@type{!number}*/ 44 this.mainId = mainId !== undefined ? mainId : -1; 45 46 /**@type{!number}*/ 47 this.subId = subId !== undefined ? subId : -1; 48 } 49 50 /** 51 * Contains all operations done between two document states 52 * in the Undo/Redo history. 53 * Possible TODO: create a context for sharing the undoRules, 54 * instead of passing them to all StateTransition instances 55 * @constructor 56 * @param {gui.UndoStateRules=} undoRules 57 * @param {!Array.<!ops.Operation>=} initialOps 58 * @param {!boolean=} editOpsPossible Set to @true if the initialOps could contain edit ops. 59 */ 60 function StateTransition(undoRules, initialOps, editOpsPossible) { 61 var /**@type{!number}*/ 62 nextStateId, 63 /**@type{!Array.<!ops.Operation>}*/ 64 operations, 65 /**@type{!number}*/ 66 editOpsCount; 67 68 /** 69 * @param {!ops.Operation} op 70 * @return {undefined} 71 */ 72 this.addOperation = function (op) { 73 if (undoRules.isEditOperation(op)) { 74 editOpsCount += 1; 75 } 76 operations.push(op); 77 }; 78 79 /** 80 * @param {!StateId} stateId 81 * @return {!boolean} 82 */ 83 this.isNextStateId = function (stateId) { 84 return (stateId.mainId === nextStateId) && (stateId.subId === editOpsCount); 85 }; 86 87 /** 88 * @return {!StateId} 89 */ 90 this.getNextStateId = function () { 91 return new StateId(nextStateId, editOpsCount); 92 }; 93 94 /** 95 * @return {!Array.<!ops.Operation>} 96 */ 97 this.getOperations = function () { 98 return operations; 99 }; 100 101 /** 102 * @param {!number} count 103 * @param {!ops.Operation} op 104 * @return {!number} 105 */ 106 function addEditOpsCount(count, op) { 107 return count + (undoRules.isEditOperation(op) ? 1 : 0); 108 } 109 110 function init() { 111 stateIdBase += 1; 112 nextStateId = stateIdBase; 113 114 operations = initialOps || []; 115 116 editOpsCount = (initialOps && editOpsPossible) ? initialOps.reduce(addEditOpsCount, 0) : 0; 117 } 118 init(); 119 } 120 121 /** 122 * @param {gui.UndoStateRules=} defaultRules 123 * @constructor 124 * @implements gui.UndoManager 125 */ 126 gui.TrivialUndoManager = function TrivialUndoManager(defaultRules) { 127 var self = this, 128 cursorns = 'urn:webodf:names:cursor', 129 domUtils = core.DomUtils, 130 /**@type{?Element}*/ 131 initialDoc, 132 /**@type{!StateTransition}*/ 133 initialStateTransition, 134 playFunc, 135 /**@type{!ops.Document}*/ 136 document, 137 /**@type {!StateId}*/ 138 unmodifiedStateId, 139 /**@type{!StateTransition}*/ 140 currentUndoStateTransition, 141 /**@type{!Array.<!StateTransition>}*/ 142 undoStateTransitions = [], 143 /**@type{!Array.<!StateTransition>}*/ 144 redoStateTransitions = [], 145 eventNotifier = new core.EventNotifier([ 146 gui.UndoManager.signalUndoStackChanged, 147 gui.UndoManager.signalUndoStateCreated, 148 gui.UndoManager.signalUndoStateModified, 149 gui.UndoManager.signalDocumentModifiedChanged, 150 gui.TrivialUndoManager.signalDocumentRootReplaced 151 ]), 152 undoRules = defaultRules || new gui.UndoStateRules(), 153 isExecutingOps = false; 154 155 /** 156 * @return {!boolean} 157 */ 158 function isModified() { 159 return currentUndoStateTransition.isNextStateId(unmodifiedStateId) !== true; 160 } 161 162 /** 163 * Execute all operations in the supplied state transition 164 * @param {!StateTransition} stateTransition 165 * @return {undefined} 166 */ 167 function executeOperations(stateTransition) { 168 var operations = stateTransition.getOperations(); 169 170 if (operations.length > 0) { 171 isExecutingOps = true; // Used to ignore operations received whilst performing an undo or redo 172 playFunc(operations); 173 isExecutingOps = false; 174 } 175 } 176 177 function emitStackChange() { 178 eventNotifier.emit(gui.UndoManager.signalUndoStackChanged, { 179 undoAvailable: self.hasUndoStates(), 180 redoAvailable: self.hasRedoStates() 181 }); 182 } 183 184 /** 185 * @param {!boolean} oldModified 186 * @return {undefined} 187 */ 188 function emitDocumentModifiedChange(oldModified) { 189 var newModified = isModified(); 190 if (oldModified !== newModified) { 191 eventNotifier.emit(gui.UndoManager.signalDocumentModifiedChanged, newModified); 192 } 193 } 194 195 /** 196 * @return {!StateTransition} 197 */ 198 function mostRecentUndoStateTransition() { 199 return undoStateTransitions[undoStateTransitions.length - 1]; 200 } 201 202 /** 203 * Pushes the currentUndoStateTransition into the undoStateTransitions if necessary 204 */ 205 function completeCurrentUndoState() { 206 if (currentUndoStateTransition !== initialStateTransition // Initial state should never be in the undo stack 207 && currentUndoStateTransition !== mostRecentUndoStateTransition()) { 208 // undoStateTransitions may already contain the current undo state if the user 209 // has moved backwards and then forwards in the undo stack 210 undoStateTransitions.push(currentUndoStateTransition); 211 } 212 } 213 214 /** 215 * @param {!Node} node 216 */ 217 function removeNode(node) { 218 var sibling = node.previousSibling || node.nextSibling; 219 node.parentNode.removeChild(node); 220 domUtils.normalizeTextNodes(sibling); 221 } 222 223 /** 224 * @param {!Element} root 225 */ 226 function removeCursors(root) { 227 domUtils.getElementsByTagNameNS(root, cursorns, "cursor").forEach(removeNode); 228 domUtils.getElementsByTagNameNS(root, cursorns, "anchor").forEach(removeNode); 229 } 230 231 /** 232 * Converts an object hash into an unordered array of its values 233 * @param {!Object} obj 234 * @return {!Array.<Object>} 235 */ 236 function values(obj) { 237 return Object.keys(obj).map(function (key) { return obj[key]; }); 238 } 239 240 /** 241 * Reduce the provided undo states to just unique AddCursor followed by 242 * MoveCursor commands for each still-present cursor. This is used when 243 * restoring the original document state at the start of an undo step 244 * @param {!Array.<!StateTransition>} undoStateTransitions 245 * @return {!StateTransition} 246 */ 247 function extractCursorStates(undoStateTransitions) { 248 var addCursor = {}, 249 moveCursor = {}, 250 requiredAddOps = {}, 251 remainingAddOps, 252 ops, 253 stateTransition = undoStateTransitions.pop(); 254 255 document.getMemberIds().forEach(function (memberid) { 256 requiredAddOps[memberid] = true; 257 }); 258 remainingAddOps = Object.keys(requiredAddOps).length; 259 260 // Every cursor that is visible on the document will need to be restored 261 // Only need the *last* move or add operation for each visible cursor, as the length & position 262 // are absolute 263 /** 264 * @param {!ops.Operation} op 265 */ 266 function processOp(op) { 267 var spec = op.spec(); 268 if (!requiredAddOps[spec.memberid]) { 269 return; 270 } 271 switch (spec.optype) { 272 case "AddCursor": 273 if (!addCursor[spec.memberid]) { 274 addCursor[spec.memberid] = op; 275 delete requiredAddOps[spec.memberid]; 276 remainingAddOps -= 1; 277 } 278 break; 279 case "MoveCursor": 280 if (!moveCursor[spec.memberid]) { 281 moveCursor[spec.memberid] = op; 282 } 283 break; 284 } 285 } 286 287 while (stateTransition && remainingAddOps > 0) { 288 ops = stateTransition.getOperations(); 289 ops.reverse(); // Want the LAST move/add operation seen 290 ops.forEach(processOp); 291 stateTransition = undoStateTransitions.pop(); 292 } 293 294 return new StateTransition(undoRules, values(addCursor).concat(values(moveCursor))); 295 } 296 297 /** 298 * Subscribe to events related to the undo manager 299 * @param {!string} signal 300 * @param {!Function} callback 301 */ 302 this.subscribe = function (signal, callback) { 303 eventNotifier.subscribe(signal, callback); 304 }; 305 306 /** 307 * Unsubscribe to events related to the undo manager 308 * @param {!string} signal 309 * @param {!Function} callback 310 */ 311 this.unsubscribe = function (signal, callback) { 312 eventNotifier.unsubscribe(signal, callback); 313 }; 314 315 316 /** 317 * @return {!boolean} 318 */ 319 this.isDocumentModified = isModified; 320 321 /** 322 * @param {!boolean} modified 323 * @return {undefined} 324 */ 325 this.setDocumentModified = function(modified) { 326 // current state is already matching the new state? 327 if (isModified() === modified) { 328 return; 329 } 330 331 if (modified) { 332 // set to invalid state 333 unmodifiedStateId = new StateId(); 334 } else { 335 unmodifiedStateId = currentUndoStateTransition.getNextStateId(); 336 } 337 338 eventNotifier.emit(gui.UndoManager.signalDocumentModifiedChanged, modified); 339 }; 340 341 /** 342 * Returns true if there are one or more undo states available 343 * @return {boolean} 344 */ 345 this.hasUndoStates = function () { 346 return undoStateTransitions.length > 0; 347 }; 348 349 /** 350 * Returns true if there are one or more redo states available 351 * @return {boolean} 352 */ 353 this.hasRedoStates = function () { 354 return redoStateTransitions.length > 0; 355 }; 356 357 /** 358 * Set the OdtDocument to operate on 359 * @param {!ops.Document} newDocument 360 */ 361 this.setDocument = function (newDocument) { 362 document = newDocument; 363 }; 364 365 /** 366 * @inheritDoc 367 */ 368 this.purgeInitialState = function () { 369 var oldModified = isModified(); 370 371 undoStateTransitions.length = 0; 372 redoStateTransitions.length = 0; 373 currentUndoStateTransition = initialStateTransition = new StateTransition(undoRules); 374 unmodifiedStateId = currentUndoStateTransition.getNextStateId(); 375 initialDoc = null; 376 emitStackChange(); 377 emitDocumentModifiedChange(oldModified); 378 }; 379 380 function setInitialState() { 381 var oldModified = isModified(); 382 383 initialDoc = document.cloneDocumentElement(); 384 // The current state may contain cursors if the initial state is modified whilst the document is in edit mode. 385 // To prevent this issue, immediately purge all cursor nodes after cloning 386 removeCursors(initialDoc); 387 completeCurrentUndoState(); 388 // We just threw away the cursors in the snapshot, so need to recover all these operations so the 389 // cursor can be re-inserted when an undo is performed 390 // TODO the last move state may not reflect a valid position in the document!!! 391 // E.g., add cursor, move to end, delete all content + saveInitialState 392 currentUndoStateTransition = initialStateTransition = extractCursorStates([initialStateTransition].concat(undoStateTransitions)); 393 undoStateTransitions.length = 0; 394 redoStateTransitions.length = 0; 395 // update unmodifiedStateId if needed 396 if (!oldModified) { 397 unmodifiedStateId = currentUndoStateTransition.getNextStateId(); 398 } 399 emitStackChange(); 400 emitDocumentModifiedChange(oldModified); 401 } 402 403 /** 404 * @inheritDoc 405 */ 406 this.setInitialState = setInitialState; 407 408 /** 409 * @inheritDoc 410 */ 411 this.initialize = function () { 412 if (!initialDoc) { 413 setInitialState(); 414 } 415 }; 416 417 /** 418 * Sets the playback function to use to re-execute operations from the undo stack. 419 * @param {!function(!Array.<!ops.Operation>)} playback_func 420 */ 421 this.setPlaybackFunction = function (playback_func) { 422 playFunc = playback_func; 423 }; 424 425 /** 426 * Track the execution of an operation, and add it to the available undo states 427 * @param {!ops.Operation} op 428 * @return {undefined} 429 */ 430 this.onOperationExecuted = function (op) { 431 if (isExecutingOps) { 432 return; // Ignore new operations generated whilst performing an undo/redo 433 } 434 435 var oldModified = isModified(); 436 437 // An edit operation is assumed to indicate the end of the initial state. The user can manually 438 // reset the initial state later with setInitialState if desired. 439 // Additionally, an edit operation received when in the middle of the undo stack should also create a new state, 440 // as the current undo state is effectively "sealed" and shouldn't gain additional document modifications. 441 if ((undoRules.isEditOperation(op) && (currentUndoStateTransition === initialStateTransition || redoStateTransitions.length > 0)) 442 || !undoRules.isPartOfOperationSet(op, currentUndoStateTransition.getOperations())) { 443 redoStateTransitions.length = 0; // Creating a new undo state should always reset the redo stack 444 completeCurrentUndoState(); 445 currentUndoStateTransition = new StateTransition(undoRules, [op], true); 446 // Every undo state *MUST* contain an edit for it to be valid for undo or redo 447 undoStateTransitions.push(currentUndoStateTransition); 448 eventNotifier.emit(gui.UndoManager.signalUndoStateCreated, { operations: currentUndoStateTransition.getOperations() }); 449 emitStackChange(); 450 } else { 451 currentUndoStateTransition.addOperation(op); 452 eventNotifier.emit(gui.UndoManager.signalUndoStateModified, { operations: currentUndoStateTransition.getOperations() }); 453 } 454 455 emitDocumentModifiedChange(oldModified); 456 }; 457 458 /** 459 * Move forward the desired number of states. Will stop when the number of 460 * states is reached, or no more redo states are available. 461 * @param {!number} states 462 * @return {!number} Returns the number of states actually moved 463 */ 464 this.moveForward = function (states) { 465 var moved = 0, 466 oldModified = isModified(), 467 redoOperations; 468 469 while (states && redoStateTransitions.length) { 470 redoOperations = redoStateTransitions.pop(); 471 undoStateTransitions.push(redoOperations); 472 executeOperations(redoOperations); 473 states -= 1; 474 moved += 1; 475 } 476 477 if (moved) { 478 // There is at least one undo stack now available due to the move forward 479 // Reset the most recent undo state to receive new (non-edit) commands again 480 currentUndoStateTransition = mostRecentUndoStateTransition(); 481 // Only report the stack has modified if moveForward actually did something 482 emitStackChange(); 483 emitDocumentModifiedChange(oldModified); 484 } 485 return moved; 486 }; 487 488 /** 489 * Move backward the desired number of states. Will stop when the number of 490 * states is reached, or no more undo states are available. 491 * @param {!number} states 492 * @return {!number} Returns the number of states actually moved 493 */ 494 this.moveBackward = function (states) { 495 var moved = 0, 496 oldModified = isModified(); 497 498 while (states && undoStateTransitions.length) { 499 redoStateTransitions.push(undoStateTransitions.pop()); 500 states -= 1; 501 moved += 1; 502 } 503 504 if (moved) { 505 // Need to reset the odt document cursor list back to nil so new cursors are correctly re-registered 506 document.getMemberIds().forEach(function (memberid) { 507 if (document.hasCursor(memberid)) { 508 document.removeCursor(memberid); 509 } 510 }); 511 // Only do actual work if moveBackward does something to the undo stacks 512 document.setDocumentElement(/**@type{!Element}*/(initialDoc.cloneNode(true))); 513 eventNotifier.emit(gui.TrivialUndoManager.signalDocumentRootReplaced, { }); 514 executeOperations(initialStateTransition); 515 undoStateTransitions.forEach(executeOperations); 516 517 // On a move back command, new ops should be subsequently 518 // evaluated for inclusion in the initial state again. This will 519 // collect other cursor movement events and store them. 520 // Without this step, an undo will always reset cursor position 521 // back to the start of the document 522 currentUndoStateTransition = mostRecentUndoStateTransition() || initialStateTransition; 523 emitStackChange(); 524 emitDocumentModifiedChange(oldModified); 525 } 526 return moved; 527 }; 528 529 function init() { 530 currentUndoStateTransition = initialStateTransition = new StateTransition(undoRules); 531 unmodifiedStateId = currentUndoStateTransition.getNextStateId(); 532 } 533 534 init(); 535 }; 536 537 /**@const*/ gui.TrivialUndoManager.signalDocumentRootReplaced = "documentRootReplaced"; 538 539 }()); 540