editor: handle undefined action state
[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
251     containsNode: function() {
252         return false;
253     },
254
255     triggerTextChangeEvent: function() {
256         var event = new events.ChangeEvent('nodeTextChange', {node: this});
257         this.document.trigger('change', event);
258     }
259 });
260
261
262 var parseXML = function(xml) {
263     var toret = $($.trim(xml));
264     if(!toret.length) {
265         throw new Error('Unable to parse XML: ' + xml);
266     }
267     return toret[0];
268
269 };
270
271 var registerTransformation = function(desc, name, target) {
272     var Transformation = transformations.createContextTransformation(desc, name);
273     target[name] = function() {
274         var instance = this,
275             args = Array.prototype.slice.call(arguments, 0);
276         return instance.transform(Transformation, args);
277     };
278 };
279
280 var registerMethod = function(methodName, method, target) {
281     if(target[methodName]) {
282         throw new Error('Cannot extend {target} with method name {methodName}. Name already exists.'
283             .replace('{target}', target)
284             .replace('{methodName}', methodName)
285         );
286     }
287     target[methodName] = method;
288 };
289
290
291 var Document = function(xml, extensions) {
292     this.undoStack = [];
293     this.redoStack = [];
294     this._currentTransaction = null;
295     this._transformationLevel = 0;
296     
297     this._nodeMethods = {};
298     this._textNodeMethods = {};
299     this._elementNodeMethods = {};
300     this._nodeTransformations = {};
301     this._textNodeTransformations = {};
302     this._elementNodeTransformations = {};
303     
304     this.registerExtension(coreTransformations);
305
306     (extensions || []).forEach(function(extension) {
307         this.registerExtension(extension);
308     }.bind(this));
309     this.loadXML(xml);
310 };
311
312 $.extend(Document.prototype, Backbone.Events, fragments, {
313     ElementNodeFactory: ElementNode,
314     TextNodeFactory: TextNode,
315
316     createDocumentNode: function(from) {
317         if(!(from instanceof Node)) {
318             if(typeof from === 'string') {
319                 from = parseXML(from);
320                 this.normalizeXML(from);
321             } else {
322                 if(from.text !== undefined) {
323                     /* globals document */
324                     from = document.createTextNode(from.text);
325                 } else {
326                     if(!from.tagName) {
327                         throw new Error('tagName missing');
328                     }
329                     var node = $('<' + from.tagName + '>');
330
331                     _.keys(from.attrs || {}).forEach(function(key) {
332                         node.attr(key, from.attrs[key]);
333                     });
334
335                     from = node[0];
336                 }
337             }
338         }
339         var Factory, typeMethods, typeTransformations;
340         if(from.nodeType === Node.TEXT_NODE) {
341             Factory = this.TextNodeFactory;
342             typeMethods = this._textNodeMethods;
343             typeTransformations = this._textNodeTransformations;
344         } else if(from.nodeType === Node.ELEMENT_NODE) {
345             Factory = this.ElementNodeFactory;
346             typeMethods = this._elementNodeMethods;
347             typeTransformations = this._elementNodeTransformations;
348         }
349         var toret = new Factory(from, this);
350         _.extend(toret, this._nodeMethods);
351         _.extend(toret, typeMethods);
352         
353         _.extend(toret, this._nodeTransformations);
354         _.extend(toret, typeTransformations);
355         
356         toret.__super__ = _.extend({}, this._nodeMethods, this._nodeTransformations);
357         _.keys(toret.__super__).forEach(function(key) {
358             toret.__super__[key] = _.bind(toret.__super__[key], toret);
359         });
360
361         return toret;
362     },
363
364     loadXML: function(xml, options) {
365         options = options || {};
366         this._defineDocumentProperties($(parseXML(xml)));
367         this.normalizeXML(this.dom);
368         if(!options.silent) {
369             this.trigger('contentSet');
370         }
371     },
372
373     normalizeXML: function(nativeNode) {
374         void(nativeNode); // noop
375     },
376
377     toXML: function() {
378         return this.root.toXML();
379     },
380
381     containsNode: function(node) {
382         return this.root && this.root.containsNode(node);
383     },
384
385     getSiblingParents: function(params) {
386         var parents1 = [params.node1].concat(params.node1.parents()).reverse(),
387             parents2 = [params.node2].concat(params.node2.parents()).reverse(),
388             noSiblingParents = null;
389
390         if(parents1.length === 0 || parents2.length === 0 || !(parents1[0].sameNode(parents2[0]))) {
391             return noSiblingParents;
392         }
393
394         var stop = Math.min(parents1.length, parents2.length),
395             i;
396         for(i = 0; i < stop; i++) {
397             if(parents1[i].sameNode(parents2[i])) {
398                 continue;
399             }
400             break;
401         }
402         if(i === stop) {
403             i--;
404         }
405         return {node1: parents1[i], node2: parents2[i]};
406     },
407
408     trigger: function() {
409         Backbone.Events.trigger.apply(this, arguments);
410     },
411
412     getNodeInsertion: function(node) {
413         var insertion = {};
414         if(node instanceof DocumentNode) {
415             insertion.ofNode = node;
416             insertion.insertsNew = !this.containsNode(node);
417         } else {
418           insertion.ofNode = this.createDocumentNode(node);
419           insertion.insertsNew = true;
420         }
421         return insertion;
422     },
423
424     registerMethod: function(methodName, method, dstName) {
425         var doc = this;
426         var destination = {
427             document: doc,
428             documentNode: doc._nodeMethods,
429             textNode: doc._textNodeMethods,
430             elementNode: doc._elementNodeMethods
431         }[dstName];
432         registerMethod(methodName, method, destination);
433     },
434
435     registerTransformation: function(desc, name, dstName) {
436         var doc = this;
437         var destination = {
438             document: doc,
439             documentNode: doc._nodeTransformations,
440             textNode: doc._textNodeTransformations,
441             elementNode: doc._elementNodeTransformations
442         }[dstName];
443         registerTransformation(desc, name, destination);
444     },
445
446     registerExtension: function(extension) {
447         var doc = this;
448
449         ['document', 'documentNode', 'elementNode', 'textNode'].forEach(function(dstName) {
450             var dstExtension = extension[dstName];
451             if(dstExtension) {
452                 if(dstExtension.methods) {
453                     _.pairs(dstExtension.methods).forEach(function(pair) {
454                         var methodName = pair[0],
455                             method = pair[1];
456
457                         doc.registerMethod(methodName, method, dstName);
458
459                     });
460                 }
461
462                 if(dstExtension.transformations) {
463                     _.pairs(dstExtension.transformations).forEach(function(pair) {
464                         var name = pair[0],
465                             desc = pair[1];
466                         doc.registerTransformation(desc, name, dstName);
467                     });
468                 }
469             }
470         });
471     },
472
473     ifChanged: function(context, action, documentChangedHandler, documentUnchangedHandler) {
474         var hasChanged = false,
475             changeMonitor = function() {
476                 hasChanged = true;
477             };
478
479         this.on('change', changeMonitor);
480         action.call(context);
481         this.off('change', changeMonitor);
482         
483         if(hasChanged) {
484             if(documentChangedHandler) {
485                 documentChangedHandler.call(context);
486             }
487         } else {
488             if(documentUnchangedHandler) {
489                 documentUnchangedHandler.call(context);
490             }
491         }
492     },
493
494     transform: function(Transformation, args) {
495         var toret, transformation;
496
497         if(!this._currentTransaction) {
498             return this.transaction(function() {
499                 return this.transform(Transformation, args);
500             }, {context: this});
501         }
502
503         if(typeof Transformation === 'function') {
504             transformation = new Transformation(this, this, args);
505         } else {
506             transformation = Transformation;
507         }
508         if(transformation) {
509             this._transformationLevel++;
510             
511             this.ifChanged(
512                 this,
513                 function() {
514                     toret = transformation.run({beUndoable:this._transformationLevel === 1});
515                 },
516                 function() {
517                     if(this._transformationLevel === 1 && !this._undoInProgress) {
518                         this._currentTransaction.pushTransformation(transformation);
519                         this.redoStack = [];
520                     }
521                 }
522             );
523
524             this._transformationLevel--;
525             return toret;
526         } else {
527             throw new Error('Transformation ' + transformation + ' doesn\'t exist!');
528         }
529     },
530     undo: function() {
531         var transaction = this.undoStack.pop(),
532             doc = this,
533             transformations, stopAt;
534
535         if(transaction) {
536             this._undoInProgress = true;
537
538             // We will modify this array in a minute so make sure we work on a copy.
539             transformations = transaction.transformations.slice(0);
540
541             if(transformations.length > 1) {
542                 // In case of real transactions we don't want to run undo on all of transformations if we don't have to.
543                 stopAt = undefined;
544                 transformations.some(function(t, idx) {
545                     if(!t.undo && t.getChangeRoot().sameNode(doc.root)) {
546                         stopAt = idx;
547                         return true; //break
548                     }
549                 });
550                 if(stopAt !== undefined) {
551                     // We will get away with undoing only this transformations as the one at stopAt reverses the whole document.
552                     transformations = transformations.slice(0, stopAt+1);
553                 }
554             }
555
556             transformations.reverse();
557             transformations.forEach(function(t) {
558                 t.undo();
559             });
560
561             this._undoInProgress = false;
562             this.redoStack.push(transaction);
563             this.trigger('operationEnd');
564         }
565     },
566     redo: function() {
567         var transaction = this.redoStack.pop();
568         if(transaction) {
569             this._transformationLevel++;
570             transaction.transformations.forEach(function(t) {
571                 t.run({beUndoable: true});
572             });
573             this._transformationLevel--;
574             this.undoStack.push(transaction);
575             this.trigger('operationEnd');
576
577         }
578     },
579
580     startTransaction: function(metadata) {
581         if(this._currentTransaction) {
582             throw new Error('Nested transactions not supported!');
583         }
584         this._rollbackBackup = this.root.clone();
585         this._currentTransaction = new Transaction([], metadata);
586     },
587
588     endTransaction: function() {
589         if(!this._currentTransaction) {
590             throw new Error('End of transaction requested, but there is no transaction in progress!');
591         }
592         if(this._currentTransaction.hasTransformations()) {
593             this.undoStack.push(this._currentTransaction);
594             this.trigger('operationEnd');
595         }
596         this._currentTransaction = null;
597     },
598
599     rollbackTransaction: function() {
600         if(!this._currentTransaction) {
601             throw new Error('Transaction rollback requested, but there is no transaction in progress!');
602         }
603         this.replaceRoot(this._rollbackBackup);
604         this._rollbackBackup = null;
605         this._currentTransaction = null;
606         this._transformationLevel = 0;
607     },
608
609     transaction: function(callback, params) {
610         var toret;
611         params = params || {};
612         this.startTransaction(params.metadata);
613         try {
614             toret = callback.call(params.context || this);
615         } catch(e) {
616             if(params.error) {
617                 params.error(e);
618             }
619             this.rollbackTransaction();
620             return;
621         }
622         this.endTransaction();
623         if(params.success) {
624             params.success(toret);
625         }
626         return toret;
627     },
628
629     getNodeByPath: function(path) {
630         var toret = this.root;
631         path.forEach(function(idx) {
632             toret = toret.contents()[idx];
633         });
634         return toret;
635     },
636
637     _defineDocumentProperties: function($document) {
638         var doc = this;
639         Object.defineProperty(doc, 'root', {get: function() {
640             if(!$document) {
641                 return null;
642             }
643             return doc.createDocumentNode($document[0]);
644         }, configurable: true});
645         Object.defineProperty(doc, 'dom', {get: function() {
646             if(!$document) {
647                 return null;
648             }
649             return $document[0];
650         }, configurable: true});
651     },
652
653     createFragment: function(Type, params) {
654         if(!Type.prototype instanceof fragments.Fragment) {
655             throw new Error('Can\'t create a fragment: `Type` is not a valid Fragment');
656         }
657         return new Type(this, params);
658     }
659 });
660
661 var Transaction = function(transformations, metadata) {
662     this.transformations = transformations || [];
663     this.metadata = metadata;
664 };
665 $.extend(Transaction.prototype, {
666     pushTransformation: function(transformation) {
667         this.transformations.push(transformation);
668     },
669     hasTransformations: function() {
670         return this.transformations.length > 0;
671     }
672 });
673
674
675 return {
676     documentFromXML: function(xml) {
677         var doc = new Document(xml);
678         return doc;
679     },
680
681     elementNodeFromXML: function(xml) {
682         return this.documentFromXML(xml).root;
683     },
684
685     Document: Document,
686     DocumentNode: DocumentNode,
687     ElementNode: ElementNode,
688     TextNode: TextNode
689 };
690
691 });