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