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