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