smartxml: handle node being a root in DocumentNode.getIndex
[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.createElementNode(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     after: INSERTION(function(nativeNode) {
86         return this._$.after(nativeNode);
87     }),
88
89     before: INSERTION(function(nativeNode) {
90         return this._$.before(nativeNode);
91     }),
92
93     wrapWith: function(node) {
94         node = node instanceof ElementNode ? node : this.document.createElementNode(node);
95
96         if(this.parent()) {
97             this.before(node);
98         }
99         node.append(this);
100         return node;
101     },
102
103     /**
104     * Removes parent of a node if node has no siblings.
105     */
106     unwrap: function() {
107         if(this.isRoot()) {
108             return;
109         }
110         var parent = this.parent(),
111             grandParent;
112         if(parent.contents().length === 1) {
113             grandParent = parent.parent();
114             parent.unwrapContent();
115             return grandParent;
116         }
117     },
118
119     triggerChangeEvent: function(type, metaData) {
120         var event = new events.ChangeEvent(type, $.extend({node: this}, metaData || {}));
121         if(type === 'nodeDetached' || this.document.containsNode(event.meta.node)) {
122             this.document.trigger('change', event);
123         }
124     },
125     
126     getNodeInsertion: function(node) {
127         var insertion = {};
128         if(node instanceof DocumentNode) {
129             insertion.ofNode = node;
130             insertion.insertsNew = !this.document.containsNode(node);
131         } else {
132           insertion.ofNode = this.document.createElementNode(node);
133           insertion.insertsNew = true;
134         }
135         return insertion;
136     },
137
138     getIndex: function() {
139         if(this.isRoot()) {
140             return 0;
141         }
142         return this.parent().indexOf(this);
143     }
144 });
145
146 var ElementNode = function(nativeNode, document) {
147     DocumentNode.call(this, nativeNode, document);
148 };
149 ElementNode.prototype = Object.create(DocumentNode.prototype);
150
151 $.extend(ElementNode.prototype, {
152     nodeType: Node.ELEMENT_NODE,
153
154     detach: function() {
155         var prev = this.prev(),
156             next = this.next();
157         if(parent) {
158             if(prev && prev.nodeType === Node.TEXT_NODE && next && next.nodeType === Node.TEXT_NODE) {
159                 prev.appendText(next.getText());
160                 next.detach();
161             }
162         }
163         return DocumentNode.prototype.detach.call(this);
164     },
165
166     setData: function(key, value) {
167         if(value !== undefined) {
168             this._$.data(key, value);
169         } else {
170             this._$.removeData(_.keys(this._$.data()));
171             this._$.data(key);
172         }
173     },
174
175     getData: function(key) {
176         if(key) {
177             return this._$.data(key);
178         }
179         return this._$.data();
180     },
181
182     getTagName: function() {
183         return this.nativeNode.tagName.toLowerCase();
184     },
185
186     contents: function() {
187         var toret = [],
188             document = this.document;
189         this._$.contents().each(function() {
190             if(this.nodeType === Node.ELEMENT_NODE) {
191                 toret.push(document.createElementNode(this));
192             }
193             else if(this.nodeType === Node.TEXT_NODE) {
194                 toret.push(document.createTextNode(this));
195             }
196         });
197         return toret;
198     },
199
200     indexOf: function(node) {
201         return this._$.contents().index(node._$);
202     },
203
204     setTag: function(tagName) {
205         var node = this.document.createElementNode({tagName: tagName}),
206             oldTagName = this.getTagName(),
207             myContents = this._$.contents();
208
209         this.getAttrs().forEach(function(attribute) {
210             node.setAttr(attribute.name, attribute.value, true);
211         });
212         node.setData(this.getData());
213
214         if(this.sameNode(this.document.root)) {
215             defineDocumentProperties(this.document, node._$);
216         }
217         this._$.replaceWith(node._$);
218         this._setNativeNode(node._$[0]);
219         this._$.append(myContents);
220         this.triggerChangeEvent('nodeTagChange', {oldTagName: oldTagName, newTagName: this.getTagName()});
221     },
222
223     getAttr: function(name) {
224         return this._$.attr(name);
225     },
226
227     setAttr: function(name, value, silent) {
228         var oldVal = this.getAttr(name);
229         this._$.attr(name, value);
230         if(!silent) {
231             this.triggerChangeEvent('nodeAttrChange', {attr: name, oldVal: oldVal, newVal: value});
232         }
233     },
234
235     getAttrs: function() {
236         var toret = [];
237         for(var i = 0; i < this.nativeNode.attributes.length; i++) {
238             toret.push(this.nativeNode.attributes[i]);
239         }
240         return toret;
241     },
242
243     append: INSERTION(function(nativeNode) {
244         this._$.append(nativeNode);
245     }),
246
247     prepend: INSERTION(function(nativeNode) {
248         this._$.prepend(nativeNode);
249     }),
250
251     unwrapContent: function() {
252         var parent = this.parent();
253         if(!parent) {
254             return;
255         }
256
257         var parentContents = parent.contents(),
258             myContents = this.contents(),
259             myIdx = parent.indexOf(this);
260
261
262         if(myContents.length === 0) {
263             return this.detach();
264         }
265
266         var moveLeftRange, moveRightRange, leftMerged;
267
268         if(myIdx > 0 && (parentContents[myIdx-1].nodeType === TEXT_NODE) && (myContents[0].nodeType === TEXT_NODE)) {
269             parentContents[myIdx-1].appendText(myContents[0].getText());
270             myContents[0].detach();
271             moveLeftRange = true;
272             leftMerged = true;
273         } else {
274             leftMerged = false;
275         }
276
277         if(!(leftMerged && myContents.length === 1)) {
278             if(myIdx < parentContents.length - 1 && (parentContents[myIdx+1].nodeType === TEXT_NODE) && (myContents[myContents.length-1].nodeType === TEXT_NODE)) {
279                 parentContents[myIdx+1].prependText(myContents[myContents.length-1].getText());
280                 myContents[myContents.length-1].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     createElementNode: function(from) {
374         if(!(from instanceof HTMLElement)) {
375             if(from.text) {
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     createTextNode: function(nativeNode) {
397         return new this.TextNodeFactory(nativeNode, this);
398     },
399
400     loadXML: function(xml, options) {
401         options = options || {};
402         defineDocumentProperties(this, $(parseXML(xml)));
403         if(!options.silent) {
404             this.trigger('contentSet');
405         }
406     },
407
408     toXML: function() {
409         return this.root.toXML();
410     },
411
412     containsNode: function(node) {
413         return this.root && (node.nativeNode === this.root.nativeNode || node._$.parents().index(this.root._$) !== -1);
414     },
415
416     wrapNodes: function(params) {
417         if(!(params.element1.parent().sameNode(params.element2.parent()))) {
418             throw new Error('Wrapping non-sibling nodes not supported.');
419         }
420
421         var parent = params.element1.parent(),
422             parentContents = parent.contents(),
423             wrapper = this.createElementNode({
424                 tagName: params._with.tagName,
425                 attrs: params._with.attrs}),
426             idx1 = parent.indexOf(params.element1),
427             idx2 = parent.indexOf(params.element2);
428
429         if(idx1 > idx2) {
430             var tmp = idx1;
431             idx1 = idx2;
432             idx2 = tmp;
433         }
434
435         var insertingMethod, insertingTarget;
436         if(idx1 === 0) {
437             insertingMethod = 'prepend';
438             insertingTarget = parent;
439         } else {
440             insertingMethod = 'after';
441             insertingTarget = parentContents[idx1-1];
442         }
443
444         for(var i = idx1; i <= idx2; i++) {
445             wrapper.append(parentContents[i].detach());
446         }
447
448         insertingTarget[insertingMethod](wrapper);
449         return wrapper;
450     },
451
452     getSiblingParents: function(params) {
453         var parents1 = [params.node1].concat(params.node1.parents()).reverse(),
454             parents2 = [params.node2].concat(params.node2.parents()).reverse(),
455             noSiblingParents = null;
456
457         if(parents1.length === 0 || parents2.length === 0 || !(parents1[0].sameNode(parents2[0]))) {
458             return noSiblingParents;
459         }
460
461         var i;
462         for(i = 0; i < Math.min(parents1.length, parents2.length); i++) {
463             if(parents1[i].sameNode(parents2[i])) {
464                 continue;
465             }
466             break;
467         }
468         return {node1: parents1[i], node2: parents2[i]};
469     },
470
471     _wrapText: function(params) {
472         params = _.extend({textNodeIdx: 0}, params);
473         if(typeof params.textNodeIdx === 'number') {
474             params.textNodeIdx = [params.textNodeIdx];
475         }
476         
477         var contentsInside = params.inside.contents(),
478             idx1 = Math.min.apply(Math, params.textNodeIdx),
479             idx2 = Math.max.apply(Math, params.textNodeIdx),
480             textNode1 = contentsInside[idx1],
481             textNode2 = contentsInside[idx2],
482             sameNode = textNode1.sameNode(textNode2),
483             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
484             prefixInside = textNode1.getText().substr(params.offsetStart),
485             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
486             suffixOutside = textNode2.getText().substr(params.offsetEnd)
487         ;
488
489         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
490             throw new Error('Wrapping text in non-sibling text nodes not supported.');
491         }
492         
493         var wrapperElement = this.createElementNode({tagName: params._with.tagName, attrs: params._with.attrs});
494         textNode1.after(wrapperElement);
495         textNode1.detach();
496         
497         if(prefixOutside.length > 0) {
498             wrapperElement.before({text:prefixOutside});
499         }
500         if(sameNode) {
501             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
502             wrapperElement.append({text: core});
503         } else {
504             textNode2.detach();
505             if(prefixInside.length > 0) {
506                 wrapperElement.append({text: prefixInside});
507             }
508             for(var i = idx1 + 1; i < idx2; i++) {
509                 wrapperElement.append(contentsInside[i]);
510             }
511             if(suffixInside.length > 0) {
512                 wrapperElement.append({text: suffixInside});
513             }
514         }
515         if(suffixOutside.length > 0) {
516             wrapperElement.after({text: suffixOutside});
517         }
518         return wrapperElement;
519     },
520
521     trigger: function() {
522         //console.log('trigger: ' + arguments[0] + (arguments[1] ? ', ' + arguments[1].type : ''));
523         Backbone.Events.trigger.apply(this, arguments);
524     }
525 });
526
527 var defineDocumentProperties = function(doc, $document) {
528     Object.defineProperty(doc, 'root', {get: function() {
529         return doc.createElementNode($document[0]);
530     }, configurable: true});
531     Object.defineProperty(doc, 'dom', {get: function() {
532         return $document[0];
533     }, configurable: true});
534 };
535
536 return {
537     documentFromXML: function(xml) {
538         return new Document(parseXML(xml));
539     },
540
541     elementNodeFromXML: function(xml) {
542         return this.documentFromXML(xml).root;
543     },
544
545     Document: Document,
546     DocumentNode: DocumentNode,
547     ElementNode: ElementNode
548 };
549
550 });