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