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