smartxml: DocumentNode.getPath
[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     clone: function() {
40         return this.document.createDocumentNode(this._$.clone(true, true)[0]);
41     },
42
43     getPath: function(ancestor) {
44         var nodePath = [this].concat(this.parents()),
45             toret, idx;
46         ancestor = ancestor || this.document.root;
47
48         nodePath.some(function(node, i) {
49             if(node.sameNode(ancestor)) {
50                 idx = i;
51                 return true;
52             }
53         });
54
55         if(idx !== 'undefined') {
56             nodePath = nodePath.slice(0, idx);
57         }
58         toret = nodePath.map(function(node) {return node.getIndex(); });
59         toret.reverse();
60         return toret;
61     },
62
63     isRoot: function() {
64         return this.document.root.sameNode(this);
65     },
66
67     detach: function() {
68         var parent = this.parent();
69         this._$.detach();
70         this.triggerChangeEvent('nodeDetached', {parent: parent});
71         return this;
72     },
73
74     sameNode: function(otherNode) {
75         return otherNode && this.nativeNode === otherNode.nativeNode;
76     },
77
78     parent: function() {
79         var parentNode = this.nativeNode.parentNode;
80         if(parentNode && parentNode.nodeType === Node.ELEMENT_NODE) {
81             return this.document.createDocumentNode(parentNode);
82         }
83         return null;
84     },
85
86     parents: function() {
87         var parent = this.parent(),
88             parents = parent ? parent.parents() : [];
89         if(parent) {
90             parents.unshift(parent);
91         }
92         return parents;
93     },
94
95     prev: function() {
96         var myIdx = this.getIndex();
97         return myIdx > 0 ? this.parent().contents()[myIdx-1] : null;
98     },
99
100     next: function() {
101         if(this.isRoot()) {
102             return null;
103         }
104         var myIdx = this.getIndex(),
105             parentContents = this.parent().contents();
106         return myIdx < parentContents.length - 1 ? parentContents[myIdx+1] : null;
107     },
108
109     isSurroundedByTextElements: function() {
110         var prev = this.prev(),
111             next = this.next();
112         return prev && (prev.nodeType === Node.TEXT_NODE) && next && (next.nodeType === Node.TEXT_NODE);
113     },
114
115     after: INSERTION(function(nativeNode) {
116         return this._$.after(nativeNode);
117     }),
118
119     before: INSERTION(function(nativeNode) {
120         return this._$.before(nativeNode);
121     }),
122
123     wrapWith: function(node) {
124         var insertion = this.getNodeInsertion(node);
125         if(this.parent()) {
126             this.before(insertion.ofNode);
127         }
128         insertion.ofNode.append(this);
129         return insertion.ofNode;
130     },
131
132     /**
133     * Removes parent of a node if node has no siblings.
134     */
135     unwrap: function() {
136         if(this.isRoot()) {
137             return;
138         }
139         var parent = this.parent(),
140             grandParent;
141         if(parent.contents().length === 1) {
142             grandParent = parent.parent();
143             parent.unwrapContent();
144             return grandParent;
145         }
146     },
147
148     triggerChangeEvent: function(type, metaData) {
149         var event = new events.ChangeEvent(type, $.extend({node: this}, metaData || {}));
150         if(type === 'nodeDetached' || this.document.containsNode(event.meta.node)) {
151             this.document.trigger('change', event);
152         }
153     },
154     
155     getNodeInsertion: function(node) {
156         var insertion = {};
157         if(node instanceof DocumentNode) {
158             insertion.ofNode = node;
159             insertion.insertsNew = !this.document.containsNode(node);
160         } else {
161           insertion.ofNode = this.document.createDocumentNode(node);
162           insertion.insertsNew = true;
163         }
164         return insertion;
165     },
166
167     getIndex: function() {
168         if(this.isRoot()) {
169             return 0;
170         }
171         return this.parent().indexOf(this);
172     }
173 });
174
175 var ElementNode = function(nativeNode, document) {
176     DocumentNode.call(this, nativeNode, document);
177 };
178 ElementNode.prototype = Object.create(DocumentNode.prototype);
179
180 $.extend(ElementNode.prototype, {
181     nodeType: Node.ELEMENT_NODE,
182
183     detach: function() {
184         var next;
185         if(parent && this.isSurroundedByTextElements()) {
186             next = this.next();
187             this.prev().appendText(next.getText());
188             next.detach();
189         }
190         return DocumentNode.prototype.detach.call(this);
191     },
192
193     setData: function(key, value) {
194         if(value !== undefined) {
195             this._$.data(key, value);
196         } else {
197             this._$.removeData(_.keys(this._$.data()));
198             this._$.data(key);
199         }
200     },
201
202     getData: function(key) {
203         if(key) {
204             return this._$.data(key);
205         }
206         return this._$.data();
207     },
208
209     getTagName: function() {
210         return this.nativeNode.tagName.toLowerCase();
211     },
212
213     contents: function() {
214         var toret = [],
215             document = this.document;
216         this._$.contents().each(function() {
217             toret.push(document.createDocumentNode(this));
218         });
219         return toret;
220     },
221
222     indexOf: function(node) {
223         return this._$.contents().index(node._$);
224     },
225
226     setTag: function(tagName) {
227         var node = this.document.createDocumentNode({tagName: tagName}),
228             oldTagName = this.getTagName(),
229             myContents = this._$.contents();
230
231         this.getAttrs().forEach(function(attribute) {
232             node.setAttr(attribute.name, attribute.value, true);
233         });
234         node.setData(this.getData());
235
236         if(this.sameNode(this.document.root)) {
237             defineDocumentProperties(this.document, node._$);
238         }
239         this._$.replaceWith(node._$);
240         this._setNativeNode(node._$[0]);
241         this._$.append(myContents);
242         this.triggerChangeEvent('nodeTagChange', {oldTagName: oldTagName, newTagName: this.getTagName()});
243     },
244
245     getAttr: function(name) {
246         return this._$.attr(name);
247     },
248
249     setAttr: function(name, value, silent) {
250         var oldVal = this.getAttr(name);
251         this._$.attr(name, value);
252         if(!silent) {
253             this.triggerChangeEvent('nodeAttrChange', {attr: name, oldVal: oldVal, newVal: value});
254         }
255     },
256
257     getAttrs: function() {
258         var toret = [];
259         for(var i = 0; i < this.nativeNode.attributes.length; i++) {
260             toret.push(this.nativeNode.attributes[i]);
261         }
262         return toret;
263     },
264
265     append: INSERTION(function(nativeNode) {
266         this._$.append(nativeNode);
267     }),
268
269     prepend: INSERTION(function(nativeNode) {
270         this._$.prepend(nativeNode);
271     }),
272
273     unwrapContent: function() {
274         var parent = this.parent();
275         if(!parent) {
276             return;
277         }
278
279         var myContents = this.contents(),
280             myIdx = parent.indexOf(this);
281
282
283         if(myContents.length === 0) {
284             return this.detach();
285         }
286
287         var prev = this.prev(),
288             next = this.next(),
289             moveLeftRange, moveRightRange, leftMerged;
290
291         if(prev && (prev.nodeType === TEXT_NODE) && (myContents[0].nodeType === TEXT_NODE)) {
292             prev.appendText(myContents[0].getText());
293             myContents[0].detach();
294             moveLeftRange = true;
295             leftMerged = true;
296         } else {
297             leftMerged = false;
298         }
299
300         if(!(leftMerged && myContents.length === 1)) {
301             var lastContents = _.last(myContents);
302             if(next && (next.nodeType === TEXT_NODE) && (lastContents.nodeType === TEXT_NODE)) {
303                 next.prependText(lastContents.getText());
304                 lastContents.detach();
305                 moveRightRange = true;
306             }
307         }
308
309         var childrenLength = this.contents().length;
310         this.contents().forEach(function(child) {
311             this.before(child);
312         }.bind(this));
313
314         this.detach();
315
316         return {
317             element1: parent.contents()[myIdx + (moveLeftRange ? -1 : 0)],
318             element2: parent.contents()[myIdx + childrenLength-1 + (moveRightRange ? 1 : 0)]
319         };
320     },
321
322     wrapText: function(params) {
323         return this.document._wrapText(_.extend({inside: this}, params));
324     },
325
326     toXML: function() {
327         var wrapper = $('<div>');
328         wrapper.append(this._getXMLDOMToDump());
329         return wrapper.html();
330     },
331     
332     _getXMLDOMToDump: function() {
333         return this._$;
334     }
335 });
336
337 var TextNode = function(nativeNode, document) {
338     DocumentNode.call(this, nativeNode, document);
339 };
340 TextNode.prototype = Object.create(DocumentNode.prototype);
341
342 $.extend(TextNode.prototype, {
343     nodeType: Node.TEXT_NODE,
344
345     getText: function() {
346         return this.nativeNode.data;
347     },
348
349     setText: function(text) {
350         this.nativeNode.data = text;
351         this.triggerTextChangeEvent();
352     },
353
354     appendText: function(text) {
355         this.nativeNode.data = this.nativeNode.data + text;
356         this.triggerTextChangeEvent();
357     },
358
359     prependText: function(text) {
360         this.nativeNode.data = text + this.nativeNode.data;
361         this.triggerTextChangeEvent();
362     },
363
364     wrapWith: function(desc) {
365         if(typeof desc.start === 'number' && typeof desc.end === 'number') {
366             return this.document._wrapText({
367                 inside: this.parent(),
368                 textNodeIdx: this.parent().indexOf(this),
369                 offsetStart: Math.min(desc.start, desc.end),
370                 offsetEnd: Math.max(desc.start, desc.end),
371                 _with: {tagName: desc.tagName, attrs: desc.attrs}
372             });
373         } else {
374             return DocumentNode.prototype.wrapWith.call(this, desc);
375         }
376     },
377
378     split: function(params) {
379         var parentElement = this.parent(),
380             passed = false,
381             succeedingChildren = [],
382             prefix = this.getText().substr(0, params.offset),
383             suffix = this.getText().substr(params.offset);
384
385         parentElement.contents().forEach(function(child) {
386             if(passed) {
387                 succeedingChildren.push(child);
388             }
389             if(child.sameNode(this)) {
390                 passed = true;
391             }
392         }.bind(this));
393
394         if(prefix.length > 0) {
395             this.setText(prefix);
396         }
397         else {
398             this.detach();
399         }
400
401         var attrs = {};
402         parentElement.getAttrs().forEach(function(attr) {attrs[attr.name] = attr.value; });
403         var newElement = this.document.createDocumentNode({tagName: parentElement.getTagName(), attrs: attrs});
404         parentElement.after(newElement);
405
406         if(suffix.length > 0) {
407             newElement.append({text: suffix});
408         }
409         succeedingChildren.forEach(function(child) {
410             newElement.append(child);
411         });
412
413         return {first: parentElement, second: newElement};
414     },
415
416     triggerTextChangeEvent: function() {
417         var event = new events.ChangeEvent('nodeTextChange', {node: this});
418         this.document.trigger('change', event);
419     }
420 });
421
422
423 var parseXML = function(xml) {
424     return $($.trim(xml))[0];
425 };
426
427 var Document = function(xml) {
428     this.loadXML(xml);
429 };
430
431 $.extend(Document.prototype, Backbone.Events, {
432     ElementNodeFactory: ElementNode,
433     TextNodeFactory: TextNode,
434
435     createDocumentNode: function(from) {
436         if(!(from instanceof Node)) {
437             if(from.text !== undefined) {
438                 from = document.createTextNode(from.text);
439             } else {
440                 var node = $('<' + from.tagName + '>');
441
442                 _.keys(from.attrs || {}).forEach(function(key) {
443                     node.attr(key, from.attrs[key]);
444                 });
445
446                 from = node[0];
447             }
448         }
449         var Factory;
450         if(from.nodeType === Node.TEXT_NODE) {
451             Factory = this.TextNodeFactory;
452         } else if(from.nodeType === Node.ELEMENT_NODE) {
453             Factory = this.ElementNodeFactory;
454         }
455         return new Factory(from, this);
456     },
457
458     loadXML: function(xml, options) {
459         options = options || {};
460         defineDocumentProperties(this, $(parseXML(xml)));
461         if(!options.silent) {
462             this.trigger('contentSet');
463         }
464     },
465
466     toXML: function() {
467         return this.root.toXML();
468     },
469
470     containsNode: function(node) {
471         return this.root && (node.nativeNode === this.root.nativeNode || node._$.parents().index(this.root._$) !== -1);
472     },
473
474     wrapNodes: function(params) {
475         if(!(params.element1.parent().sameNode(params.element2.parent()))) {
476             throw new Error('Wrapping non-sibling nodes not supported.');
477         }
478
479         var parent = params.element1.parent(),
480             parentContents = parent.contents(),
481             wrapper = this.createDocumentNode({
482                 tagName: params._with.tagName,
483                 attrs: params._with.attrs}),
484             idx1 = parent.indexOf(params.element1),
485             idx2 = parent.indexOf(params.element2);
486
487         if(idx1 > idx2) {
488             var tmp = idx1;
489             idx1 = idx2;
490             idx2 = tmp;
491         }
492
493         var insertingMethod, insertingTarget;
494         if(idx1 === 0) {
495             insertingMethod = 'prepend';
496             insertingTarget = parent;
497         } else {
498             insertingMethod = 'after';
499             insertingTarget = parentContents[idx1-1];
500         }
501
502         for(var i = idx1; i <= idx2; i++) {
503             wrapper.append(parentContents[i].detach());
504         }
505
506         insertingTarget[insertingMethod](wrapper);
507         return wrapper;
508     },
509
510     getSiblingParents: function(params) {
511         var parents1 = [params.node1].concat(params.node1.parents()).reverse(),
512             parents2 = [params.node2].concat(params.node2.parents()).reverse(),
513             noSiblingParents = null;
514
515         if(parents1.length === 0 || parents2.length === 0 || !(parents1[0].sameNode(parents2[0]))) {
516             return noSiblingParents;
517         }
518
519         var i;
520         for(i = 0; i < Math.min(parents1.length, parents2.length); i++) {
521             if(parents1[i].sameNode(parents2[i])) {
522                 continue;
523             }
524             break;
525         }
526         return {node1: parents1[i], node2: parents2[i]};
527     },
528
529     _wrapText: function(params) {
530         params = _.extend({textNodeIdx: 0}, params);
531         if(typeof params.textNodeIdx === 'number') {
532             params.textNodeIdx = [params.textNodeIdx];
533         }
534         
535         var contentsInside = params.inside.contents(),
536             idx1 = Math.min.apply(Math, params.textNodeIdx),
537             idx2 = Math.max.apply(Math, params.textNodeIdx),
538             textNode1 = contentsInside[idx1],
539             textNode2 = contentsInside[idx2],
540             sameNode = textNode1.sameNode(textNode2),
541             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
542             prefixInside = textNode1.getText().substr(params.offsetStart),
543             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
544             suffixOutside = textNode2.getText().substr(params.offsetEnd)
545         ;
546
547         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
548             throw new Error('Wrapping text in non-sibling text nodes not supported.');
549         }
550         
551         var wrapperElement = this.createDocumentNode({tagName: params._with.tagName, attrs: params._with.attrs});
552         textNode1.after(wrapperElement);
553         textNode1.detach();
554         
555         if(prefixOutside.length > 0) {
556             wrapperElement.before({text:prefixOutside});
557         }
558         if(sameNode) {
559             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
560             wrapperElement.append({text: core});
561         } else {
562             textNode2.detach();
563             if(prefixInside.length > 0) {
564                 wrapperElement.append({text: prefixInside});
565             }
566             for(var i = idx1 + 1; i < idx2; i++) {
567                 wrapperElement.append(contentsInside[i]);
568             }
569             if(suffixInside.length > 0) {
570                 wrapperElement.append({text: suffixInside});
571             }
572         }
573         if(suffixOutside.length > 0) {
574             wrapperElement.after({text: suffixOutside});
575         }
576         return wrapperElement;
577     },
578
579     trigger: function() {
580         //console.log('trigger: ' + arguments[0] + (arguments[1] ? ', ' + arguments[1].type : ''));
581         Backbone.Events.trigger.apply(this, arguments);
582     }
583 });
584
585 var defineDocumentProperties = function(doc, $document) {
586     Object.defineProperty(doc, 'root', {get: function() {
587         return doc.createDocumentNode($document[0]);
588     }, configurable: true});
589     Object.defineProperty(doc, 'dom', {get: function() {
590         return $document[0];
591     }, configurable: true});
592 };
593
594 return {
595     documentFromXML: function(xml) {
596         return new Document(xml);
597     },
598
599     elementNodeFromXML: function(xml) {
600         return this.documentFromXML(xml).root;
601     },
602
603     Document: Document,
604     DocumentNode: DocumentNode,
605     ElementNode: ElementNode
606 };
607
608 });