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