0a1a3545683f9e2c82170a40532a4ff4fbc5608a
[fnpeditor.git] / src / smartxml / smartxml.js
1 define([
2     'libs/jquery',
3     'libs/underscore',
4     'libs/backbone',
5     'smartxml/events'
6 ], function($, _, Backbone, events) {
7     
8 'use strict';
9
10
11 var TEXT_NODE = Node.TEXT_NODE;
12
13
14 var INSERTION = function(implementation) {
15     var toret = function(node) {
16         var insertion = this.getNodeInsertion(node);
17         implementation.call(this, insertion.ofNode.nativeNode);
18         this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode});
19         return insertion.ofNode;
20     };
21     return toret;
22 };
23
24 var DocumentNode = function(nativeNode, document) {
25     if(!document) {
26         throw new Error('undefined document for a node');
27     }
28     this.document = document;
29     this._setNativeNode(nativeNode);
30
31 };
32
33 $.extend(DocumentNode.prototype, {
34     _setNativeNode: function(nativeNode) {
35         this.nativeNode = nativeNode;
36         this._$ = $(nativeNode);
37     },
38
39     isRoot: function() {
40         return this.document.root.sameNode(this);
41     },
42
43     detach: function() {
44         var parent = this.parent();
45         this._$.detach();
46         this.triggerChangeEvent('nodeDetached', {parent: parent});
47         return this;
48     },
49
50     sameNode: function(otherNode) {
51         return otherNode && this.nativeNode === otherNode.nativeNode;
52     },
53
54     parent: function() {
55         var parentNode = this.nativeNode.parentNode;
56         if(parentNode && parentNode.nodeType === Node.ELEMENT_NODE) {
57             return this.document.createDocumentNode(parentNode);
58         }
59         return null;
60     },
61
62     parents: function() {
63         var parent = this.parent(),
64             parents = parent ? parent.parents() : [];
65         if(parent) {
66             parents.unshift(parent);
67         }
68         return parents;
69     },
70
71     prev: function() {
72         var myIdx = this.getIndex();
73         return myIdx > 0 ? this.parent().contents()[myIdx-1] : null;
74     },
75
76     next: function() {
77         if(this.isRoot()) {
78             return null;
79         }
80         var myIdx = this.getIndex(),
81             parentContents = this.parent().contents();
82         return myIdx < parentContents.length - 1 ? parentContents[myIdx+1] : null;
83     },
84
85     isSurroundedByTextElements: function() {
86         var prev = this.prev(),
87             next = this.next();
88         return prev && (prev.nodeType === Node.TEXT_NODE) && next && (next.nodeType === Node.TEXT_NODE);
89     },
90
91     after: INSERTION(function(nativeNode) {
92         return this._$.after(nativeNode);
93     }),
94
95     before: INSERTION(function(nativeNode) {
96         return this._$.before(nativeNode);
97     }),
98
99     wrapWith: function(node) {
100         var insertion = this.getNodeInsertion(node);
101         if(this.parent()) {
102             this.before(insertion.ofNode);
103         }
104         insertion.ofNode.append(this);
105         return insertion.ofNode;
106     },
107
108     /**
109     * Removes parent of a node if node has no siblings.
110     */
111     unwrap: function() {
112         if(this.isRoot()) {
113             return;
114         }
115         var parent = this.parent(),
116             grandParent;
117         if(parent.contents().length === 1) {
118             grandParent = parent.parent();
119             parent.unwrapContent();
120             return grandParent;
121         }
122     },
123
124     triggerChangeEvent: function(type, metaData) {
125         var event = new events.ChangeEvent(type, $.extend({node: this}, metaData || {}));
126         if(type === 'nodeDetached' || this.document.containsNode(event.meta.node)) {
127             this.document.trigger('change', event);
128         }
129     },
130     
131     getNodeInsertion: function(node) {
132         var insertion = {};
133         if(node instanceof DocumentNode) {
134             insertion.ofNode = node;
135             insertion.insertsNew = !this.document.containsNode(node);
136         } else {
137           insertion.ofNode = this.document.createDocumentNode(node);
138           insertion.insertsNew = true;
139         }
140         return insertion;
141     },
142
143     getIndex: function() {
144         if(this.isRoot()) {
145             return 0;
146         }
147         return this.parent().indexOf(this);
148     }
149 });
150
151 var ElementNode = function(nativeNode, document) {
152     DocumentNode.call(this, nativeNode, document);
153 };
154 ElementNode.prototype = Object.create(DocumentNode.prototype);
155
156 $.extend(ElementNode.prototype, {
157     nodeType: Node.ELEMENT_NODE,
158
159     detach: function() {
160         var next;
161         if(parent && this.isSurroundedByTextElements()) {
162             next = this.next();
163             this.prev().appendText(next.getText());
164             next.detach();
165         }
166         return DocumentNode.prototype.detach.call(this);
167     },
168
169     setData: function(key, value) {
170         if(value !== undefined) {
171             this._$.data(key, value);
172         } else {
173             this._$.removeData(_.keys(this._$.data()));
174             this._$.data(key);
175         }
176     },
177
178     getData: function(key) {
179         if(key) {
180             return this._$.data(key);
181         }
182         return this._$.data();
183     },
184
185     getTagName: function() {
186         return this.nativeNode.tagName.toLowerCase();
187     },
188
189     contents: function() {
190         var toret = [],
191             document = this.document;
192         this._$.contents().each(function() {
193             toret.push(document.createDocumentNode(this));
194         });
195         return toret;
196     },
197
198     indexOf: function(node) {
199         return this._$.contents().index(node._$);
200     },
201
202     setTag: function(tagName) {
203         var node = this.document.createDocumentNode({tagName: tagName}),
204             oldTagName = this.getTagName(),
205             myContents = this._$.contents();
206
207         this.getAttrs().forEach(function(attribute) {
208             node.setAttr(attribute.name, attribute.value, true);
209         });
210         node.setData(this.getData());
211
212         if(this.sameNode(this.document.root)) {
213             defineDocumentProperties(this.document, node._$);
214         }
215         this._$.replaceWith(node._$);
216         this._setNativeNode(node._$[0]);
217         this._$.append(myContents);
218         this.triggerChangeEvent('nodeTagChange', {oldTagName: oldTagName, newTagName: this.getTagName()});
219     },
220
221     getAttr: function(name) {
222         return this._$.attr(name);
223     },
224
225     setAttr: function(name, value, silent) {
226         var oldVal = this.getAttr(name);
227         this._$.attr(name, value);
228         if(!silent) {
229             this.triggerChangeEvent('nodeAttrChange', {attr: name, oldVal: oldVal, newVal: value});
230         }
231     },
232
233     getAttrs: function() {
234         var toret = [];
235         for(var i = 0; i < this.nativeNode.attributes.length; i++) {
236             toret.push(this.nativeNode.attributes[i]);
237         }
238         return toret;
239     },
240
241     append: INSERTION(function(nativeNode) {
242         this._$.append(nativeNode);
243     }),
244
245     prepend: INSERTION(function(nativeNode) {
246         this._$.prepend(nativeNode);
247     }),
248
249     unwrapContent: function() {
250         var parent = this.parent();
251         if(!parent) {
252             return;
253         }
254
255         var myContents = this.contents(),
256             myIdx = parent.indexOf(this);
257
258
259         if(myContents.length === 0) {
260             return this.detach();
261         }
262
263         var prev = this.prev(),
264             next = this.next(),
265             moveLeftRange, moveRightRange, leftMerged;
266
267         if(prev && (prev.nodeType === TEXT_NODE) && (myContents[0].nodeType === TEXT_NODE)) {
268             prev.appendText(myContents[0].getText());
269             myContents[0].detach();
270             moveLeftRange = true;
271             leftMerged = true;
272         } else {
273             leftMerged = false;
274         }
275
276         if(!(leftMerged && myContents.length === 1)) {
277             var lastContents = _.last(myContents);
278             if(next && (next.nodeType === TEXT_NODE) && (lastContents.nodeType === TEXT_NODE)) {
279                 next.prependText(lastContents.getText());
280                 lastContents.detach();
281                 moveRightRange = true;
282             }
283         }
284
285         var childrenLength = this.contents().length;
286         this.contents().forEach(function(child) {
287             this.before(child);
288         }.bind(this));
289
290         this.detach();
291
292         return {
293             element1: parent.contents()[myIdx + (moveLeftRange ? -1 : 0)],
294             element2: parent.contents()[myIdx + childrenLength-1 + (moveRightRange ? 1 : 0)]
295         };
296     },
297
298     wrapText: function(params) {
299         return this.document._wrapText(_.extend({inside: this}, params));
300     },
301
302     toXML: function() {
303         var wrapper = $('<div>');
304         wrapper.append(this._getXMLDOMToDump());
305         return wrapper.html();
306     },
307     
308     _getXMLDOMToDump: function() {
309         return this._$;
310     }
311 });
312
313 var TextNode = function(nativeNode, document) {
314     DocumentNode.call(this, nativeNode, document);
315 };
316 TextNode.prototype = Object.create(DocumentNode.prototype);
317
318 $.extend(TextNode.prototype, {
319     nodeType: Node.TEXT_NODE,
320
321     getText: function() {
322         return this.nativeNode.data;
323     },
324
325     setText: function(text) {
326         this.nativeNode.data = text;
327         this.triggerTextChangeEvent();
328     },
329
330     appendText: function(text) {
331         this.nativeNode.data = this.nativeNode.data + text;
332         this.triggerTextChangeEvent();
333     },
334
335     prependText: function(text) {
336         this.nativeNode.data = text + this.nativeNode.data;
337         this.triggerTextChangeEvent();
338     },
339
340     wrapWith: function(desc) {
341         if(typeof desc.start === 'number' && typeof desc.end === 'number') {
342             return this.document._wrapText({
343                 inside: this.parent(),
344                 textNodeIdx: this.parent().indexOf(this),
345                 offsetStart: Math.min(desc.start, desc.end),
346                 offsetEnd: Math.max(desc.start, desc.end),
347                 _with: {tagName: desc.tagName, attrs: desc.attrs}
348             });
349         } else {
350             return DocumentNode.prototype.wrapWith.call(this, desc);
351         }
352     },
353
354     triggerTextChangeEvent: function() {
355         var event = new events.ChangeEvent('nodeTextChange', {node: this});
356         this.document.trigger('change', event);
357     }
358 });
359
360
361 var parseXML = function(xml) {
362     return $(xml)[0];
363 };
364
365 var Document = function(xml) {
366     this.loadXML(xml);
367 };
368
369 $.extend(Document.prototype, Backbone.Events, {
370     ElementNodeFactory: ElementNode,
371     TextNodeFactory: TextNode,
372
373     createDocumentNode: function(from) {
374         if(!(from instanceof Node)) {
375             if(from.text !== undefined) {
376                 from = document.createTextNode(from.text);
377             } else {
378                 var node = $('<' + from.tagName + '>');
379
380                 _.keys(from.attrs || {}).forEach(function(key) {
381                     node.attr(key, from.attrs[key]);
382                 });
383
384                 from = node[0];
385             }
386         }
387         var Factory;
388         if(from.nodeType === Node.TEXT_NODE) {
389             Factory = this.TextNodeFactory;
390         } else if(from.nodeType === Node.ELEMENT_NODE) {
391             Factory = this.ElementNodeFactory;
392         }
393         return new Factory(from, this);
394     },
395
396     loadXML: function(xml, options) {
397         options = options || {};
398         defineDocumentProperties(this, $(parseXML(xml)));
399         if(!options.silent) {
400             this.trigger('contentSet');
401         }
402     },
403
404     toXML: function() {
405         return this.root.toXML();
406     },
407
408     containsNode: function(node) {
409         return this.root && (node.nativeNode === this.root.nativeNode || node._$.parents().index(this.root._$) !== -1);
410     },
411
412     wrapNodes: function(params) {
413         if(!(params.element1.parent().sameNode(params.element2.parent()))) {
414             throw new Error('Wrapping non-sibling nodes not supported.');
415         }
416
417         var parent = params.element1.parent(),
418             parentContents = parent.contents(),
419             wrapper = this.createDocumentNode({
420                 tagName: params._with.tagName,
421                 attrs: params._with.attrs}),
422             idx1 = parent.indexOf(params.element1),
423             idx2 = parent.indexOf(params.element2);
424
425         if(idx1 > idx2) {
426             var tmp = idx1;
427             idx1 = idx2;
428             idx2 = tmp;
429         }
430
431         var insertingMethod, insertingTarget;
432         if(idx1 === 0) {
433             insertingMethod = 'prepend';
434             insertingTarget = parent;
435         } else {
436             insertingMethod = 'after';
437             insertingTarget = parentContents[idx1-1];
438         }
439
440         for(var i = idx1; i <= idx2; i++) {
441             wrapper.append(parentContents[i].detach());
442         }
443
444         insertingTarget[insertingMethod](wrapper);
445         return wrapper;
446     },
447
448     getSiblingParents: function(params) {
449         var parents1 = [params.node1].concat(params.node1.parents()).reverse(),
450             parents2 = [params.node2].concat(params.node2.parents()).reverse(),
451             noSiblingParents = null;
452
453         if(parents1.length === 0 || parents2.length === 0 || !(parents1[0].sameNode(parents2[0]))) {
454             return noSiblingParents;
455         }
456
457         var i;
458         for(i = 0; i < Math.min(parents1.length, parents2.length); i++) {
459             if(parents1[i].sameNode(parents2[i])) {
460                 continue;
461             }
462             break;
463         }
464         return {node1: parents1[i], node2: parents2[i]};
465     },
466
467     _wrapText: function(params) {
468         params = _.extend({textNodeIdx: 0}, params);
469         if(typeof params.textNodeIdx === 'number') {
470             params.textNodeIdx = [params.textNodeIdx];
471         }
472         
473         var contentsInside = params.inside.contents(),
474             idx1 = Math.min.apply(Math, params.textNodeIdx),
475             idx2 = Math.max.apply(Math, params.textNodeIdx),
476             textNode1 = contentsInside[idx1],
477             textNode2 = contentsInside[idx2],
478             sameNode = textNode1.sameNode(textNode2),
479             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
480             prefixInside = textNode1.getText().substr(params.offsetStart),
481             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
482             suffixOutside = textNode2.getText().substr(params.offsetEnd)
483         ;
484
485         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
486             throw new Error('Wrapping text in non-sibling text nodes not supported.');
487         }
488         
489         var wrapperElement = this.createDocumentNode({tagName: params._with.tagName, attrs: params._with.attrs});
490         textNode1.after(wrapperElement);
491         textNode1.detach();
492         
493         if(prefixOutside.length > 0) {
494             wrapperElement.before({text:prefixOutside});
495         }
496         if(sameNode) {
497             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
498             wrapperElement.append({text: core});
499         } else {
500             textNode2.detach();
501             if(prefixInside.length > 0) {
502                 wrapperElement.append({text: prefixInside});
503             }
504             for(var i = idx1 + 1; i < idx2; i++) {
505                 wrapperElement.append(contentsInside[i]);
506             }
507             if(suffixInside.length > 0) {
508                 wrapperElement.append({text: suffixInside});
509             }
510         }
511         if(suffixOutside.length > 0) {
512             wrapperElement.after({text: suffixOutside});
513         }
514         return wrapperElement;
515     },
516
517     trigger: function() {
518         //console.log('trigger: ' + arguments[0] + (arguments[1] ? ', ' + arguments[1].type : ''));
519         Backbone.Events.trigger.apply(this, arguments);
520     }
521 });
522
523 var defineDocumentProperties = function(doc, $document) {
524     Object.defineProperty(doc, 'root', {get: function() {
525         return doc.createDocumentNode($document[0]);
526     }, configurable: true});
527     Object.defineProperty(doc, 'dom', {get: function() {
528         return $document[0];
529     }, configurable: true});
530 };
531
532 return {
533     documentFromXML: function(xml) {
534         return new Document(parseXML(xml));
535     },
536
537     elementNodeFromXML: function(xml) {
538         return this.documentFromXML(xml).root;
539     },
540
541     Document: Document,
542     DocumentNode: DocumentNode,
543     ElementNode: ElementNode
544 };
545
546 });