smartxml: DocumentNode.getIndex function for syntactic sugar
[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     getIndex: function() {
84         return this.parent().indexOf(this);
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         this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode});
177         return insertion.ofNode;
178     },
179
180     prepend: function(node) {
181         var insertion = this.getNodeInsertion(node);
182         this._$.prepend(insertion.ofNode.nativeNode);
183         this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode});
184         return insertion.ofNode;
185     },
186
187     unwrapContent: function() {
188         var parent = this.parent();
189         if(!parent) {
190             return;
191         }
192
193         var parentContents = parent.contents(),
194             myContents = this.contents(),
195             myIdx = parent.indexOf(this);
196
197         if(myContents.length === 0) {
198             return this.detach();
199         }
200
201         var moveLeftRange, moveRightRange, leftMerged;
202
203         if(myIdx > 0 && (parentContents[myIdx-1].nodeType === TEXT_NODE) && (myContents[0].nodeType === TEXT_NODE)) {
204             parentContents[myIdx-1].appendText(myContents[0].getText());
205             myContents[0].detach();
206             moveLeftRange = true;
207             leftMerged = true;
208         } else {
209             leftMerged = false;
210         }
211
212         if(!(leftMerged && myContents.length === 1)) {
213             if(myIdx < parentContents.length - 1 && (parentContents[myIdx+1].nodeType === TEXT_NODE) && (myContents[myContents.length-1].nodeType === TEXT_NODE)) {
214                 parentContents[myIdx+1].prependText(myContents[myContents.length-1].getText());
215                 myContents[myContents.length-1].detach();
216                 moveRightRange = true;
217             }
218         }
219
220         var childrenLength = this.contents().length;
221         this.contents().forEach(function(child) {
222             this.before(child);
223         }.bind(this));
224
225         this.detach();
226
227         return {
228             element1: parent.contents()[myIdx + (moveLeftRange ? -1 : 0)],
229             element2: parent.contents()[myIdx + childrenLength-1 + (moveRightRange ? 1 : 0)]
230         };
231     },
232
233     wrapText: function(params) {
234         return this.document._wrapText(_.extend({inside: this}, params));
235     },
236
237     toXML: function() {
238         var wrapper = $('<div>');
239         wrapper.append(this._getXMLDOMToDump());
240         return wrapper.html();
241     },
242     
243     _getXMLDOMToDump: function() {
244         return this._$;
245     }
246 });
247
248 var TextNode = function(nativeNode, document) {
249     DocumentNode.call(this, nativeNode, document);
250 };
251 TextNode.prototype = Object.create(DocumentNode.prototype);
252
253 $.extend(TextNode.prototype, {
254     nodeType: Node.TEXT_NODE,
255
256     getText: function() {
257         return this.nativeNode.data;
258     },
259
260     setText: function(text) {
261         this.nativeNode.data = text;
262         this.triggerTextChangeEvent();
263     },
264
265     appendText: function(text) {
266         this.nativeNode.data = this.nativeNode.data + text;
267         this.triggerTextChangeEvent();
268     },
269
270     prependText: function(text) {
271         this.nativeNode.data = text + this.nativeNode.data;
272         this.triggerTextChangeEvent();
273     },
274
275     wrapWith: function(desc) {
276         if(typeof desc.start === 'number' && typeof desc.end === 'number') {
277             return this.document._wrapText({
278                 inside: this.parent(),
279                 textNodeIdx: this.parent().indexOf(this),
280                 offsetStart: Math.min(desc.start, desc.end),
281                 offsetEnd: Math.max(desc.start, desc.end),
282                 _with: {tagName: desc.tagName, attrs: desc.attrs}
283             });
284         } else {
285             return DocumentNode.prototype.wrapWith.call(this, desc);
286         }
287     },
288
289     triggerTextChangeEvent: function() {
290         var event = new events.ChangeEvent('nodeTextChange', {node: this});
291         this.document.trigger('change', event);
292     }
293 });
294
295
296 var parseXML = function(xml) {
297     return $(xml)[0];
298 };
299
300 var Document = function(xml) {
301     this.loadXML(xml);
302 };
303
304 $.extend(Document.prototype, Backbone.Events, {
305     ElementNodeFactory: ElementNode,
306     TextNodeFactory: TextNode,
307
308     createElementNode: function(from) {
309         if(!(from instanceof HTMLElement)) {
310             if(from.text) {
311                 from = document.createTextNode(from.text);
312             } else {
313                 var node = $('<' + from.tagName + '>');
314
315                 _.keys(from.attrs || {}).forEach(function(key) {
316                     node.attr(key, from.attrs[key]);
317                 });
318
319                 from = node[0];
320             }
321         }
322         return new this.ElementNodeFactory(from, this);
323     },
324
325     createTextNode: function(nativeNode) {
326         return new this.TextNodeFactory(nativeNode, this);
327     },
328
329     loadXML: function(xml, options) {
330         options = options || {};
331         defineDocumentProperties(this, $(parseXML(xml)));
332         if(!options.silent) {
333             this.trigger('contentSet');
334         }
335     },
336
337     toXML: function() {
338         return this.root.toXML();
339     },
340
341     containsNode: function(node) {
342         return node._$.parents().index(this.root._$) !== -1;
343     },
344
345     wrapNodes: function(params) {
346         if(!(params.element1.parent().sameNode(params.element2.parent()))) {
347             throw new Error('Wrapping non-sibling nodes not supported.');
348         }
349
350         var parent = params.element1.parent(),
351             parentContents = parent.contents(),
352             wrapper = this.createElementNode({
353                 tagName: params._with.tagName,
354                 attrs: params._with.attrs}),
355             idx1 = parent.indexOf(params.element1),
356             idx2 = parent.indexOf(params.element2);
357
358         if(idx1 > idx2) {
359             var tmp = idx1;
360             idx1 = idx2;
361             idx2 = tmp;
362         }
363
364         var insertingMethod, insertingTarget;
365         if(idx1 === 0) {
366             insertingMethod = 'prepend';
367             insertingTarget = parent;
368         } else {
369             insertingMethod = 'after';
370             insertingTarget = parentContents[idx1-1];
371         }
372
373         for(var i = idx1; i <= idx2; i++) {
374             wrapper.append(parentContents[i].detach());
375         }
376
377         insertingTarget[insertingMethod](wrapper);
378         return wrapper;
379     },
380
381     _wrapText: function(params) {
382         params = _.extend({textNodeIdx: 0}, params);
383         if(typeof params.textNodeIdx === 'number') {
384             params.textNodeIdx = [params.textNodeIdx];
385         }
386         
387         var contentsInside = params.inside.contents(),
388             idx1 = Math.min.apply(Math, params.textNodeIdx),
389             idx2 = Math.max.apply(Math, params.textNodeIdx),
390             textNode1 = contentsInside[idx1],
391             textNode2 = contentsInside[idx2],
392             sameNode = textNode1.sameNode(textNode2),
393             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
394             prefixInside = textNode1.getText().substr(params.offsetStart),
395             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
396             suffixOutside = textNode2.getText().substr(params.offsetEnd)
397         ;
398
399         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
400             throw new Error('Wrapping text in non-sibling text nodes not supported.');
401         }
402         
403         var wrapperElement = this.createElementNode({tagName: params._with.tagName, attrs: params._with.attrs});
404         textNode1.after(wrapperElement);
405         textNode1.detach();
406         
407         if(prefixOutside.length > 0) {
408             wrapperElement.before({text:prefixOutside});
409         }
410         if(sameNode) {
411             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
412             wrapperElement.append({text: core});
413         } else {
414             textNode2.detach();
415             if(prefixInside.length > 0) {
416                 wrapperElement.append({text: prefixInside});
417             }
418             for(var i = idx1 + 1; i < idx2; i++) {
419                 wrapperElement.append(contentsInside[i]);
420             }
421             if(suffixInside.length > 0) {
422                 wrapperElement.append({text: suffixInside});
423             }
424         }
425         if(suffixOutside.length > 0) {
426             wrapperElement.after({text: suffixOutside});
427         }
428         return wrapperElement;
429     }
430 });
431
432 var defineDocumentProperties = function(doc, $document) {
433     Object.defineProperty(doc, 'root', {get: function() {
434         return doc.createElementNode($document[0]);
435     }, configurable: true});
436     Object.defineProperty(doc, 'dom', {get: function() {
437         return $document[0];
438     }, configurable: true});
439 };
440
441 return {
442     documentFromXML: function(xml) {
443         return new Document(parseXML(xml));
444     },
445
446     elementNodeFromXML: function(xml) {
447         return this.documentFromXML(xml).root;
448     },
449
450     Document: Document,
451     DocumentNode: DocumentNode,
452     ElementNode: ElementNode
453 };
454
455 });