smartxml: transaction rollback fix
[fnpeditor.git] / src / smartxml / smartxml.js
1 define([
2     'libs/jquery',
3     'libs/underscore',
4     'libs/backbone',
5     'smartxml/events',
6     'smartxml/transformations',
7     'smartxml/core'
8 ], function($, _, Backbone, events, transformations, coreTransformations) {
9     
10 'use strict';
11 /* globals Node */
12
13
14 var DocumentNode = function(nativeNode, document) {
15     if(!document) {
16         throw new Error('undefined document for a node');
17     }
18     this.document = document;
19     this._setNativeNode(nativeNode);
20
21 };
22
23 $.extend(DocumentNode.prototype, {
24
25     transform: function(Transformation, args) {
26         var transformation = new Transformation(this.document, this, args);
27         return this.document.transform(transformation);
28     },
29
30     _setNativeNode: function(nativeNode) {
31         this.nativeNode = nativeNode;
32         this._$ = $(nativeNode);
33     },
34
35     clone: function() {
36         var clone = this._$.clone(true, true),
37             node = this;
38         clone.find('*').addBack().each(function() {
39             var el = this,
40                 clonedData = $(this).data();
41
42             _.pairs(clonedData).forEach(function(pair) {
43                 var key = pair[0],
44                     value = pair[1];
45                 if(_.isFunction(value.clone)) {
46                     clonedData[key] = value.clone(node.document.createDocumentNode(el));
47                 }
48             });
49         });
50         return this.document.createDocumentNode(clone[0]);
51     },
52
53     getPath: function(ancestor) {
54         if(!(this.document.containsNode(this))) {
55             return null;
56         }
57
58         var nodePath = [this].concat(this.parents()),
59             toret, idx;
60         ancestor = ancestor || this.document.root;
61
62         nodePath.some(function(node, i) {
63             if(node.sameNode(ancestor)) {
64                 idx = i;
65                 return true;
66             }
67         });
68
69         if(idx !== undefined) {
70             nodePath = nodePath.slice(0, idx);
71         }
72         toret = nodePath.map(function(node) {return node.getIndex(); });
73         toret.reverse();
74         return toret;
75     },
76
77     isRoot: function() {
78         return this.document.root.sameNode(this);
79     },
80
81     sameNode: function(otherNode) {
82         return !!(otherNode) && this.nativeNode === otherNode.nativeNode;
83     },
84
85     parent: function() {
86         var parentNode = this.nativeNode.parentNode;
87         if(parentNode && parentNode.nodeType === Node.ELEMENT_NODE) {
88             return this.document.createDocumentNode(parentNode);
89         }
90         return null;
91     },
92
93     parents: function() {
94         var parent = this.parent(),
95             parents = parent ? parent.parents() : [];
96         if(parent) {
97             parents.unshift(parent);
98         }
99         return parents;
100     },
101
102     prev: function() {
103         var myIdx = this.getIndex();
104         return myIdx > 0 ? this.parent().contents()[myIdx-1] : null;
105     },
106
107     next: function() {
108         if(this.isRoot()) {
109             return null;
110         }
111         var myIdx = this.getIndex(),
112             parentContents = this.parent().contents();
113         return myIdx < parentContents.length - 1 ? parentContents[myIdx+1] : null;
114     },
115
116     isSurroundedByTextElements: function() {
117         var prev = this.prev(),
118             next = this.next();
119         return prev && (prev.nodeType === Node.TEXT_NODE) && next && (next.nodeType === Node.TEXT_NODE);
120     },
121
122     triggerChangeEvent: function(type, metaData, origParent, nodeWasContained) {
123         var node = (metaData && metaData.node) ? metaData.node : this,
124             event = new events.ChangeEvent(type, $.extend({node: node}, metaData || {}));
125         if(type === 'nodeDetached' || this.document.containsNode(event.meta.node)) {
126             if(type === 'nodeMoved') {
127                 event.meta.parent = origParent;
128             }
129             this.document.trigger('change', event);
130         }
131         if((type === 'nodeAdded' || type === 'nodeMoved') && !this.document.containsNode(this) && nodeWasContained) {
132              event = new events.ChangeEvent('nodeDetached', {node: node, parent: origParent});
133              this.document.trigger('change', event);
134         }
135     },
136     
137     getNodeInsertion: function(node) {
138         return this.document.getNodeInsertion(node);
139     },
140
141     getIndex: function() {
142         if(this.isRoot()) {
143             return 0;
144         }
145         return this.parent().indexOf(this);
146     }
147 });
148
149
150 var ElementNode = function(nativeNode, document) {
151     DocumentNode.call(this, nativeNode, document);
152 };
153 ElementNode.prototype = Object.create(DocumentNode.prototype);
154
155 $.extend(ElementNode.prototype, {
156     nodeType: Node.ELEMENT_NODE,
157
158     setData: function(key, value) {
159         if(value !== undefined) {
160             this._$.data(key, value);
161         } else {
162             this._$.removeData(_.keys(this._$.data()));
163             this._$.data(key);
164         }
165     },
166
167     getData: function(key) {
168         if(key) {
169             return this._$.data(key);
170         }
171         return this._$.data();
172     },
173
174     getTagName: function() {
175         return this.nativeNode.tagName.toLowerCase();
176     },
177
178     contents: function(selector) {
179         var toret = [],
180             document = this.document;
181         if(selector) {
182             this._$.children(selector).each(function() {
183                 toret.push(document.createDocumentNode(this));
184             });
185         } else {
186             this._$.contents().each(function() {
187                 toret.push(document.createDocumentNode(this));
188             });
189         }
190         return toret;
191     },
192
193     indexOf: function(node) {
194         return this._$.contents().index(node._$);
195     },
196
197     getAttr: function(name) {
198         return this._$.attr(name);
199     },
200
201     getAttrs: function() {
202         var toret = [];
203         for(var i = 0; i < this.nativeNode.attributes.length; i++) {
204             toret.push(this.nativeNode.attributes[i]);
205         }
206         return toret;
207     },
208
209     containsNode: function(node) {
210         return node && (node.nativeNode === this.nativeNode || node._$.parents().index(this._$) !== -1);
211     },
212
213     toXML: function() {
214         var wrapper = $('<div>');
215         wrapper.append(this._getXMLDOMToDump());
216         return wrapper.html();
217     },
218     
219     _getXMLDOMToDump: function() {
220         return this._$;
221     }
222 });
223
224
225 var TextNode = function(nativeNode, document) {
226     DocumentNode.call(this, nativeNode, document);
227 };
228 TextNode.prototype = Object.create(DocumentNode.prototype);
229
230 $.extend(TextNode.prototype, {
231     nodeType: Node.TEXT_NODE,
232
233     getText: function() {
234         return this.nativeNode.data;
235     },
236
237     triggerTextChangeEvent: function() {
238         var event = new events.ChangeEvent('nodeTextChange', {node: this});
239         this.document.trigger('change', event);
240     }
241 });
242
243
244 var parseXML = function(xml) {
245     var toret = $($.trim(xml));
246     if(!toret.length) {
247         throw new Error('Unable to parse XML: ' + xml);
248     }
249     return toret[0];
250
251 };
252
253 var registerTransformation = function(desc, name, target) {
254     var Transformation = transformations.createContextTransformation(desc, name);
255     target[name] = function() {
256         var instance = this,
257             args = Array.prototype.slice.call(arguments, 0);
258         return instance.transform(Transformation, args);
259     };
260 };
261
262 var registerMethod = function(methodName, method, target) {
263     if(target[methodName]) {
264         throw new Error('Cannot extend {target} with method name {methodName}. Name already exists.'
265             .replace('{target}', target)
266             .replace('{methodName}', methodName)
267         );
268     }
269     target[methodName] = method;
270 };
271
272
273 var Document = function(xml, extensions) {
274     this.undoStack = [];
275     this.redoStack = [];
276     this._currentTransaction = null;
277     this._transformationLevel = 0;
278     
279     this._nodeMethods = {};
280     this._textNodeMethods = {};
281     this._elementNodeMethods = {};
282     this._nodeTransformations = {};
283     this._textNodeTransformations = {};
284     this._elementNodeTransformations = {};
285     
286     this.registerExtension(coreTransformations);
287
288     (extensions || []).forEach(function(extension) {
289         this.registerExtension(extension);
290     }.bind(this));
291     this.loadXML(xml);
292 };
293
294 $.extend(Document.prototype, Backbone.Events, {
295     ElementNodeFactory: ElementNode,
296     TextNodeFactory: TextNode,
297
298     createDocumentNode: function(from) {
299         if(!(from instanceof Node)) {
300             if(typeof from === 'string') {
301                 from = parseXML(from);
302                 this.normalizeXML(from);
303             } else {
304                 if(from.text !== undefined) {
305                     /* globals document */
306                     from = document.createTextNode(from.text);
307                 } else {
308                     if(!from.tagName) {
309                         throw new Error('tagName missing');
310                     }
311                     var node = $('<' + from.tagName + '>');
312
313                     _.keys(from.attrs || {}).forEach(function(key) {
314                         node.attr(key, from.attrs[key]);
315                     });
316
317                     from = node[0];
318                 }
319             }
320         }
321         var Factory, typeMethods, typeTransformations;
322         if(from.nodeType === Node.TEXT_NODE) {
323             Factory = this.TextNodeFactory;
324             typeMethods = this._textNodeMethods;
325             typeTransformations = this._textNodeTransformations;
326         } else if(from.nodeType === Node.ELEMENT_NODE) {
327             Factory = this.ElementNodeFactory;
328             typeMethods = this._elementNodeMethods;
329             typeTransformations = this._elementNodeTransformations;
330         }
331         var toret = new Factory(from, this);
332         _.extend(toret, this._nodeMethods);
333         _.extend(toret, typeMethods);
334         
335         _.extend(toret, this._nodeTransformations);
336         _.extend(toret, typeTransformations);
337         
338         toret.__super__ = _.extend({}, this._nodeMethods, this._nodeTransformations);
339         _.keys(toret.__super__).forEach(function(key) {
340             toret.__super__[key] = _.bind(toret.__super__[key], toret);
341         });
342
343         return toret;
344     },
345
346     loadXML: function(xml, options) {
347         options = options || {};
348         this._defineDocumentProperties($(parseXML(xml)));
349         this.normalizeXML(this.dom);
350         if(!options.silent) {
351             this.trigger('contentSet');
352         }
353     },
354
355     normalizeXML: function(nativeNode) {
356         void(nativeNode); // noop
357     },
358
359     toXML: function() {
360         return this.root.toXML();
361     },
362
363     containsNode: function(node) {
364         return this.root && this.root.containsNode(node);
365     },
366
367     getSiblingParents: function(params) {
368         var parents1 = [params.node1].concat(params.node1.parents()).reverse(),
369             parents2 = [params.node2].concat(params.node2.parents()).reverse(),
370             noSiblingParents = null;
371
372         if(parents1.length === 0 || parents2.length === 0 || !(parents1[0].sameNode(parents2[0]))) {
373             return noSiblingParents;
374         }
375
376         var stop = Math.min(parents1.length, parents2.length),
377             i;
378         for(i = 0; i < stop; i++) {
379             if(parents1[i].sameNode(parents2[i])) {
380                 continue;
381             }
382             break;
383         }
384         if(i === stop) {
385             i--;
386         }
387         return {node1: parents1[i], node2: parents2[i]};
388     },
389
390     trigger: function() {
391         Backbone.Events.trigger.apply(this, arguments);
392     },
393
394     getNodeInsertion: function(node) {
395         var insertion = {};
396         if(node instanceof DocumentNode) {
397             insertion.ofNode = node;
398             insertion.insertsNew = !this.containsNode(node);
399         } else {
400           insertion.ofNode = this.createDocumentNode(node);
401           insertion.insertsNew = true;
402         }
403         return insertion;
404     },
405
406     registerMethod: function(methodName, method, dstName) {
407         var doc = this;
408         var destination = {
409             document: doc,
410             documentNode: doc._nodeMethods,
411             textNode: doc._textNodeMethods,
412             elementNode: doc._elementNodeMethods
413         }[dstName];
414         registerMethod(methodName, method, destination);
415     },
416
417     registerTransformation: function(desc, name, dstName) {
418         var doc = this;
419         var destination = {
420             document: doc,
421             documentNode: doc._nodeTransformations,
422             textNode: doc._textNodeTransformations,
423             elementNode: doc._elementNodeTransformations
424         }[dstName];
425         registerTransformation(desc, name, destination);
426     },
427
428     registerExtension: function(extension) {
429         var doc = this;
430
431         ['document', 'documentNode', 'elementNode', 'textNode'].forEach(function(dstName) {
432             var dstExtension = extension[dstName];
433             if(dstExtension) {
434                 if(dstExtension.methods) {
435                     _.pairs(dstExtension.methods).forEach(function(pair) {
436                         var methodName = pair[0],
437                             method = pair[1];
438
439                         doc.registerMethod(methodName, method, dstName);
440
441                     });
442                 }
443
444                 if(dstExtension.transformations) {
445                     _.pairs(dstExtension.transformations).forEach(function(pair) {
446                         var name = pair[0],
447                             desc = pair[1];
448                         doc.registerTransformation(desc, name, dstName);
449                     });
450                 }
451             }
452         });
453     },
454
455     ifChanged: function(context, action, documentChangedHandler, documentUnchangedHandler) {
456         var hasChanged = false,
457             changeMonitor = function() {
458                 hasChanged = true;
459             };
460
461         this.on('change', changeMonitor);
462         action.call(context);
463         this.off('change', changeMonitor);
464         
465         if(hasChanged) {
466             if(documentChangedHandler) {
467                 documentChangedHandler.call(context);
468             }
469         } else {
470             if(documentUnchangedHandler) {
471                 documentUnchangedHandler.call(context);
472             }
473         }
474     },
475
476     transform: function(Transformation, args) {
477         var toret, transformation;
478
479         if(!this._currentTransaction) {
480             return this.transaction(function() {
481                 return this.transform(Transformation, args);
482             }, this);
483         }
484
485         if(typeof Transformation === 'function') {
486             transformation = new Transformation(this, this, args);
487         } else {
488             transformation = Transformation;
489         }
490         if(transformation) {
491             this._transformationLevel++;
492             
493             this.ifChanged(
494                 this,
495                 function() {
496                     toret = transformation.run({beUndoable:this._transformationLevel === 1});
497                 },
498                 function() {
499                     if(this._transformationLevel === 1 && !this._undoInProgress) {
500                         this._currentTransaction.pushTransformation(transformation);
501                         this.redoStack = [];
502                     }
503                 }
504             );
505
506             this._transformationLevel--;
507             return toret;
508         } else {
509             throw new Error('Transformation ' + transformation + ' doesn\'t exist!');
510         }
511     },
512     undo: function() {
513         var transaction = this.undoStack.pop(),
514             doc = this,
515             transformations, stopAt;
516
517         if(transaction) {
518             this._undoInProgress = true;
519
520             // We will modify this array in a minute so make sure we work on a copy.
521             transformations = transaction.transformations.slice(0);
522
523             if(transformations.length > 1) {
524                 // In case of real transactions we don't want to run undo on all of transformations if we don't have to.
525                 stopAt = undefined;
526                 transformations.some(function(t, idx) {
527                     if(!t.undo && t.getChangeRoot().sameNode(doc.root)) {
528                         stopAt = idx;
529                         return true; //break
530                     }
531                 });
532                 if(stopAt !== undefined) {
533                     // We will get away with undoing only this transformations as the one at stopAt reverses the whole document.
534                     transformations = transformations.slice(0, stopAt+1);
535                 }
536             }
537
538             transformations.reverse();
539             transformations.forEach(function(t) {
540                 t.undo();
541             });
542
543             this._undoInProgress = false;
544             this.redoStack.push(transaction);
545         }
546     },
547     redo: function() {
548         var transaction = this.redoStack.pop();
549         if(transaction) {
550             this._transformationLevel++;
551             transaction.transformations.forEach(function(t) {
552                 t.run({beUndoable: true});
553             });
554             this._transformationLevel--;
555             this.undoStack.push(transaction);
556         }
557     },
558
559     startTransaction: function(metadata) {
560         if(this._currentTransaction) {
561             throw new Error('Nested transactions not supported!');
562         }
563         this._rollbackBackup = this.root.clone();
564         this._currentTransaction = new Transaction([], metadata);
565     },
566
567     endTransaction: function() {
568         if(!this._currentTransaction) {
569             throw new Error('End of transaction requested, but there is no transaction in progress!');
570         }
571         if(this._currentTransaction.hasTransformations()) {
572             this.undoStack.push(this._currentTransaction);
573         }
574         this._currentTransaction = null;
575     },
576
577     rollbackTransaction: function() {
578         if(!this._currentTransaction) {
579             throw new Error('Transaction rollback requested, but there is no transaction in progress!');
580         }
581         this.replaceRoot(this._rollbackBackup);
582         this._rollbackBackup = null;
583         this._currentTransaction = null;
584         this._transformationLevel = 0;
585     },
586
587     transaction: function(callback, params) {
588         var toret;
589         params = params || {};
590         this.startTransaction(params.metadata);
591         try {
592             toret = callback.call(params.context || this);
593         } catch(e) {
594             if(params.error) {
595                 params.error(e);
596             }
597             this.rollbackTransaction();
598             return;
599         }
600         this.endTransaction();
601         if(params.success) {
602             params.success(toret);
603         }
604         return toret;
605     },
606
607     getNodeByPath: function(path) {
608         var toret = this.root;
609         path.forEach(function(idx) {
610             toret = toret.contents()[idx];
611         });
612         return toret;
613     },
614
615     _defineDocumentProperties: function($document) {
616         var doc = this;
617         Object.defineProperty(doc, 'root', {get: function() {
618             if(!$document) {
619                 return null;
620             }
621             return doc.createDocumentNode($document[0]);
622         }, configurable: true});
623         Object.defineProperty(doc, 'dom', {get: function() {
624             if(!$document) {
625                 return null;
626             }
627             return $document[0];
628         }, configurable: true});
629     }
630 });
631
632 var Transaction = function(transformations, metadata) {
633     this.transformations = transformations || [];
634     this.metadata = metadata;
635 };
636 $.extend(Transaction.prototype, {
637     pushTransformation: function(transformation) {
638         this.transformations.push(transformation);
639     },
640     hasTransformations: function() {
641         return this.transformations.length > 0;
642     }
643 });
644
645
646 return {
647     documentFromXML: function(xml) {
648         var doc = new Document(xml);
649         return doc;
650     },
651
652     elementNodeFromXML: function(xml) {
653         return this.documentFromXML(xml).root;
654     },
655
656     Document: Document,
657     DocumentNode: DocumentNode,
658     ElementNode: ElementNode,
659     TextNode: TextNode
660 };
661
662 });