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