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