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