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