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