b4690680a53c752fb35b760baee913a0752b6ce2
[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 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     _setNativeNode: function(nativeNode) {
25         this.nativeNode = nativeNode;
26         this._$ = $(nativeNode);
27     },
28
29     detach: function() {
30         this._$.detach();
31         return this;
32     },
33
34     sameNode: function(otherNode) {
35         return otherNode && this.nativeNode === otherNode.nativeNode;
36     },
37
38     parent: function() {
39         return this.nativeNode.parentNode ? this.document.createElementNode(this.nativeNode.parentNode) : null;
40     },
41
42     after: function(node) {
43         var insertion = this.getNodeInsertion(node);
44         this._$.after(insertion.ofNode.nativeNode);
45         this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode});
46         return insertion.ofNode;
47     },
48
49     before: function(node) {
50         var insertion = this.getNodeInsertion(node);
51         this._$.before(insertion.ofNode.nativeNode);
52         this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode});
53         return insertion.ofNode;
54     },
55
56     wrapWith: function(node) {
57         node = node instanceof ElementNode ? node : this.document.createElementNode(node);
58
59         if(this.parent()) {
60             this.before(node);
61         }
62         node.append(this);
63         return node;
64     },
65
66     triggerChangeEvent: function(type, metaData) {
67         var event = new events.ChangeEvent(type, $.extend({node: this}, metaData || {}));
68         this.document.trigger('change', event);
69     },
70     
71     getNodeInsertion: function(node) {
72         var insertion = {};
73         if(node instanceof DocumentNode) {
74             insertion.ofNode = node;
75             insertion.insertsNew = !this.document.containsNode(node);
76         } else {
77           insertion.ofNode = this.document.createElementNode(node);
78           insertion.insertsNew = true;
79         }
80         return insertion;
81     }
82 });
83
84 var ElementNode = function(nativeNode, document) {
85     DocumentNode.call(this, nativeNode, document);
86 };
87 ElementNode.prototype = Object.create(DocumentNode.prototype);
88
89 $.extend(ElementNode.prototype, {
90     nodeType: Node.ELEMENT_NODE,
91
92     setData: function(key, value) {
93         if(value !== undefined) {
94             this._$.data(key, value);
95         } else {
96             this._$.removeData(_.keys(this._$.data()));
97             this._$.data(key);
98         }
99     },
100
101     getData: function(key) {
102         if(key) {
103             return this._$.data(key);
104         }
105         return this._$.data();
106     },
107
108     getTagName: function() {
109         return this.nativeNode.tagName.toLowerCase();
110     },
111
112     contents: function() {
113         var toret = [],
114             document = this.document;
115         this._$.contents().each(function() {
116             if(this.nodeType === Node.ELEMENT_NODE) {
117                 toret.push(document.createElementNode(this));
118             }
119             else if(this.nodeType === Node.TEXT_NODE) {
120                 toret.push(document.createTextNode(this));
121             }
122         });
123         return toret;
124     },
125
126     indexOf: function(node) {
127         return this._$.contents().index(node._$);
128     },
129
130     setTag: function(tagName) {
131         var node = this.document.createElementNode({tagName: tagName}),
132             oldTagName = this.getTagName(),
133             myContents = this._$.contents();
134
135         this.getAttrs().forEach(function(attribute) {
136             node.setAttr(attribute.name, attribute.value, true);
137         });
138         node.setData(this.getData());
139
140         if(this.sameNode(this.document.root)) {
141             defineDocumentProperties(this.document, node._$);
142         }
143         this._$.replaceWith(node._$);
144         this._setNativeNode(node._$[0]);
145         this._$.append(myContents);
146         this.triggerChangeEvent('nodeTagChange', {oldTagName: oldTagName, newTagName: this.getTagName()});
147     },
148
149     getAttr: function(name) {
150         return this._$.attr(name);
151     },
152
153     setAttr: function(name, value, silent) {
154         var oldVal = this.getAttr(name);
155         this._$.attr(name, value);
156         if(!silent) {
157             this.triggerChangeEvent('nodeAttrChange', {attr: name, oldVal: oldVal, newVal: value});
158         }
159     },
160
161     getAttrs: function() {
162         var toret = [];
163         for(var i = 0; i < this.nativeNode.attributes.length; i++) {
164             toret.push(this.nativeNode.attributes[i]);
165         }
166         return toret;
167     },
168
169     append: function(node) {
170         var insertion = this.getNodeInsertion(node);
171         this._$.append(insertion.ofNode.nativeNode);
172         this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode});
173         return insertion.ofNode;
174     },
175
176     prepend: function(node) {
177         var insertion = this.getNodeInsertion(node);
178         this._$.prepend(insertion.ofNode.nativeNode);
179         this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode});
180         return insertion.ofNode;
181     },
182
183     unwrapContent: function() {
184         var parent = this.parent();
185         if(!parent) {
186             return;
187         }
188
189         var parentContents = parent.contents(),
190             myContents = this.contents(),
191             myIdx = parent.indexOf(this);
192
193         if(myContents.length === 0) {
194             return this.detach();
195         }
196
197         var moveLeftRange, moveRightRange, leftMerged;
198
199         if(myIdx > 0 && (parentContents[myIdx-1].nodeType === TEXT_NODE) && (myContents[0].nodeType === TEXT_NODE)) {
200             parentContents[myIdx-1].appendText(myContents[0].getText());
201             myContents[0].detach();
202             moveLeftRange = true;
203             leftMerged = true;
204         } else {
205             leftMerged = false;
206         }
207
208         if(!(leftMerged && myContents.length === 1)) {
209             if(myIdx < parentContents.length - 1 && (parentContents[myIdx+1].nodeType === TEXT_NODE) && (myContents[myContents.length-1].nodeType === TEXT_NODE)) {
210                 parentContents[myIdx+1].prependText(myContents[myContents.length-1].getText());
211                 myContents[myContents.length-1].detach();
212                 moveRightRange = true;
213             }
214         }
215
216         var childrenLength = this.contents().length;
217         this.contents().forEach(function(child) {
218             this.before(child);
219         }.bind(this));
220
221         this.detach();
222
223         return {
224             element1: parent.contents()[myIdx + (moveLeftRange ? -1 : 0)],
225             element2: parent.contents()[myIdx + childrenLength-1 + (moveRightRange ? 1 : 0)]
226         };
227     },
228
229     wrapText: function(params) {
230         return this.document._wrapText(_.extend({inside: this}, params));
231     },
232
233     toXML: function() {
234         var wrapper = $('<div>');
235         wrapper.append(this._getXMLDOMToDump());
236         return wrapper.html();
237     },
238     
239     _getXMLDOMToDump: function() {
240         return this._$;
241     }
242 });
243
244 var TextNode = function(nativeNode, document) {
245     DocumentNode.call(this, nativeNode, document);
246 };
247 TextNode.prototype = Object.create(DocumentNode.prototype);
248
249 $.extend(TextNode.prototype, {
250     nodeType: Node.TEXT_NODE,
251
252     getText: function() {
253         return this.nativeNode.data;
254     },
255
256     setText: function(text) {
257         this.nativeNode.data = text;
258         this.triggerTextChangeEvent();
259     },
260
261     appendText: function(text) {
262         this.nativeNode.data = this.nativeNode.data + text;
263         this.triggerTextChangeEvent();
264     },
265
266     prependText: function(text) {
267         this.nativeNode.data = text + this.nativeNode.data;
268         this.triggerTextChangeEvent();
269     },
270
271     wrapWith: function(desc) {
272         if(typeof desc.start === 'number' && typeof desc.end === 'number') {
273             return this.document._wrapText({
274                 inside: this.parent(),
275                 textNodeIdx: this.parent().indexOf(this),
276                 offsetStart: Math.min(desc.start, desc.end),
277                 offsetEnd: Math.max(desc.start, desc.end),
278                 _with: {tagName: desc.tagName, attrs: desc.attrs}
279             });
280         } else {
281             return DocumentNode.prototype.wrapWith.call(this, desc);
282         }
283     },
284
285     triggerTextChangeEvent: function() {
286         var event = new events.ChangeEvent('nodeTextChange', {node: this});
287         this.document.trigger('change', event);
288     }
289 });
290
291
292 var parseXML = function(xml) {
293     return $(xml)[0];
294 };
295
296 var Document = function(xml) {
297     this.loadXML(xml);
298 };
299
300 $.extend(Document.prototype, Backbone.Events, {
301     ElementNodeFactory: ElementNode,
302     TextNodeFactory: TextNode,
303
304     createElementNode: function(from) {
305         if(!(from instanceof HTMLElement)) {
306             if(from.text) {
307                 from = document.createTextNode(from.text);
308             } else {
309                 var node = $('<' + from.tagName + '>');
310
311                 _.keys(from.attrs || {}).forEach(function(key) {
312                     node.attr(key, from.attrs[key]);
313                 });
314
315                 from = node[0];
316             }
317         }
318         return new this.ElementNodeFactory(from, this);
319     },
320
321     createTextNode: function(nativeNode) {
322         return new this.TextNodeFactory(nativeNode, this);
323     },
324
325     loadXML: function(xml, options) {
326         options = options || {};
327         defineDocumentProperties(this, $(parseXML(xml)));
328         if(!options.silent) {
329             this.trigger('contentSet');
330         }
331     },
332
333     toXML: function() {
334         return this.root.toXML();
335     },
336
337     containsNode: function(node) {
338         return node._$.parents().index(this.root._$) !== -1;
339     },
340
341     wrapNodes: function(params) {
342         if(!(params.element1.parent().sameNode(params.element2.parent()))) {
343             throw new Error('Wrapping non-sibling nodes not supported.');
344         }
345
346         var parent = params.element1.parent(),
347             parentContents = parent.contents(),
348             wrapper = this.createElementNode({
349                 tagName: params._with.tagName,
350                 attrs: params._with.attrs}),
351             idx1 = parent.indexOf(params.element1),
352             idx2 = parent.indexOf(params.element2);
353
354         if(idx1 > idx2) {
355             var tmp = idx1;
356             idx1 = idx2;
357             idx2 = tmp;
358         }
359
360         var insertingMethod, insertingTarget;
361         if(idx1 === 0) {
362             insertingMethod = 'prepend';
363             insertingTarget = parent;
364         } else {
365             insertingMethod = 'after';
366             insertingTarget = parentContents[idx1-1];
367         }
368
369         for(var i = idx1; i <= idx2; i++) {
370             wrapper.append(parentContents[i].detach());
371         }
372
373         insertingTarget[insertingMethod](wrapper);
374         return wrapper;
375     },
376
377     _wrapText: function(params) {
378         params = _.extend({textNodeIdx: 0}, params);
379         if(typeof params.textNodeIdx === 'number') {
380             params.textNodeIdx = [params.textNodeIdx];
381         }
382         
383         var contentsInside = params.inside.contents(),
384             idx1 = Math.min.apply(Math, params.textNodeIdx),
385             idx2 = Math.max.apply(Math, params.textNodeIdx),
386             textNode1 = contentsInside[idx1],
387             textNode2 = contentsInside[idx2],
388             sameNode = textNode1.sameNode(textNode2),
389             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
390             prefixInside = textNode1.getText().substr(params.offsetStart),
391             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
392             suffixOutside = textNode2.getText().substr(params.offsetEnd)
393         ;
394
395         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
396             throw new Error('Wrapping text in non-sibling text nodes not supported.');
397         }
398         
399         var wrapperElement = this.createElementNode({tagName: params._with.tagName, attrs: params._with.attrs});
400         textNode1.after(wrapperElement);
401         textNode1.detach();
402         
403         if(prefixOutside.length > 0) {
404             wrapperElement.before({text:prefixOutside});
405         }
406         if(sameNode) {
407             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
408             wrapperElement.append({text: core});
409         } else {
410             textNode2.detach();
411             if(prefixInside.length > 0) {
412                 wrapperElement.append({text: prefixInside});
413             }
414             for(var i = idx1 + 1; i < idx2; i++) {
415                 wrapperElement.append(contentsInside[i]);
416             }
417             if(suffixInside.length > 0) {
418                 wrapperElement.append({text: suffixInside});
419             }
420         }
421         if(suffixOutside.length > 0) {
422             wrapperElement.after({text: suffixOutside});
423         }
424         return wrapperElement;
425     }
426 });
427
428 var defineDocumentProperties = function(doc, $document) {
429     Object.defineProperty(doc, 'root', {get: function() {
430         return doc.createElementNode($document[0]);
431     }, configurable: true});
432     Object.defineProperty(doc, 'dom', {get: function() {
433         return $document[0];
434     }, configurable: true});
435 };
436
437 return {
438     documentFromXML: function(xml) {
439         return new Document(parseXML(xml));
440     },
441
442     elementNodeFromXML: function(xml) {
443         return this.documentFromXML(xml).root;
444     },
445
446     Document: Document,
447     DocumentNode: DocumentNode,
448     ElementNode: ElementNode
449 };
450
451 });