smartxml: fixing text node creation
[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         var Factory;
323         if(from.nodeType === Node.TEXT_NODE) {
324             Factory = this.TextNodeFactory;
325         } else if(from.nodeType === Node.ELEMENT_NODE) {
326             Factory = this.ElementNodeFactory;
327         }
328         return new Factory(from, this);
329     },
330
331     createTextNode: function(nativeNode) {
332         return new this.TextNodeFactory(nativeNode, this);
333     },
334
335     loadXML: function(xml, options) {
336         options = options || {};
337         defineDocumentProperties(this, $(parseXML(xml)));
338         if(!options.silent) {
339             this.trigger('contentSet');
340         }
341     },
342
343     toXML: function() {
344         return this.root.toXML();
345     },
346
347     containsNode: function(node) {
348         return node._$.parents().index(this.root._$) !== -1;
349     },
350
351     wrapNodes: function(params) {
352         if(!(params.element1.parent().sameNode(params.element2.parent()))) {
353             throw new Error('Wrapping non-sibling nodes not supported.');
354         }
355
356         var parent = params.element1.parent(),
357             parentContents = parent.contents(),
358             wrapper = this.createElementNode({
359                 tagName: params._with.tagName,
360                 attrs: params._with.attrs}),
361             idx1 = parent.indexOf(params.element1),
362             idx2 = parent.indexOf(params.element2);
363
364         if(idx1 > idx2) {
365             var tmp = idx1;
366             idx1 = idx2;
367             idx2 = tmp;
368         }
369
370         var insertingMethod, insertingTarget;
371         if(idx1 === 0) {
372             insertingMethod = 'prepend';
373             insertingTarget = parent;
374         } else {
375             insertingMethod = 'after';
376             insertingTarget = parentContents[idx1-1];
377         }
378
379         for(var i = idx1; i <= idx2; i++) {
380             wrapper.append(parentContents[i].detach());
381         }
382
383         insertingTarget[insertingMethod](wrapper);
384         return wrapper;
385     },
386
387     _wrapText: function(params) {
388         params = _.extend({textNodeIdx: 0}, params);
389         if(typeof params.textNodeIdx === 'number') {
390             params.textNodeIdx = [params.textNodeIdx];
391         }
392         
393         var contentsInside = params.inside.contents(),
394             idx1 = Math.min.apply(Math, params.textNodeIdx),
395             idx2 = Math.max.apply(Math, params.textNodeIdx),
396             textNode1 = contentsInside[idx1],
397             textNode2 = contentsInside[idx2],
398             sameNode = textNode1.sameNode(textNode2),
399             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
400             prefixInside = textNode1.getText().substr(params.offsetStart),
401             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
402             suffixOutside = textNode2.getText().substr(params.offsetEnd)
403         ;
404
405         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
406             throw new Error('Wrapping text in non-sibling text nodes not supported.');
407         }
408         
409         var wrapperElement = this.createElementNode({tagName: params._with.tagName, attrs: params._with.attrs});
410         textNode1.after(wrapperElement);
411         textNode1.detach();
412         
413         if(prefixOutside.length > 0) {
414             wrapperElement.before({text:prefixOutside});
415         }
416         if(sameNode) {
417             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
418             wrapperElement.append({text: core});
419         } else {
420             textNode2.detach();
421             if(prefixInside.length > 0) {
422                 wrapperElement.append({text: prefixInside});
423             }
424             for(var i = idx1 + 1; i < idx2; i++) {
425                 wrapperElement.append(contentsInside[i]);
426             }
427             if(suffixInside.length > 0) {
428                 wrapperElement.append({text: suffixInside});
429             }
430         }
431         if(suffixOutside.length > 0) {
432             wrapperElement.after({text: suffixOutside});
433         }
434         return wrapperElement;
435     }
436 });
437
438 var defineDocumentProperties = function(doc, $document) {
439     Object.defineProperty(doc, 'root', {get: function() {
440         return doc.createElementNode($document[0]);
441     }, configurable: true});
442     Object.defineProperty(doc, 'dom', {get: function() {
443         return $document[0];
444     }, configurable: true});
445 };
446
447 return {
448     documentFromXML: function(xml) {
449         return new Document(parseXML(xml));
450     },
451
452     elementNodeFromXML: function(xml) {
453         return this.documentFromXML(xml).root;
454     },
455
456     Document: Document,
457     DocumentNode: DocumentNode,
458     ElementNode: ElementNode
459 };
460
461 });