56e78af035cdcd63be4e501c9f0dd6b9078d7cea
[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() { this._$.detach(); },
30
31     sameNode: function(otherNode) {
32         return otherNode && this.nativeNode === otherNode.nativeNode;
33     },
34
35     parent: function() {
36         return this.nativeNode.parentNode ? this.document.createElementNode(this.nativeNode.parentNode) : null;
37     },
38
39     after: function(node) {
40         node = node instanceof ElementNode ? node : this.document.createElementNode(node);
41         this._$.after(node.nativeNode);
42         return node;
43     },
44
45     before: function(node) {
46         node = node instanceof ElementNode ? node : this.document.createElementNode(node);
47         this._$.before(node.nativeNode);
48         return node;
49     },
50
51     wrapWith: function(node) {
52         node = node instanceof ElementNode ? node : this.document.createElementNode(node);
53
54         if(this.parent()) {
55             this.before(node);
56         }
57         node.append(this);
58         return node;
59     },
60
61     triggerChangeEvent: function(type, metaData) {
62         var event = new events.ChangeEvent(type, $.extend({node: this}, metaData || {}));
63         this.document.trigger('change', event);
64     },
65 });
66
67 var ElementNode = function(nativeNode, document) {
68     DocumentNode.call(this, nativeNode, document);
69 };
70 ElementNode.prototype = Object.create(DocumentNode.prototype);
71
72 $.extend(ElementNode.prototype, {
73     nodeType: Node.ELEMENT_NODE,
74
75     setData: function(key, value) {
76         if(value !== undefined) {
77             this._$.data(key, value);
78         } else {
79             this._$.removeData(_.keys(this._$.data()));
80             this._$.data(key);
81         }
82     },
83
84     getData: function(key) {
85         if(key) {
86             return this._$.data(key);
87         }
88         return this._$.data();
89     },
90
91     getTagName: function() {
92         return this.nativeNode.tagName.toLowerCase();
93     },
94
95     contents: function() {
96         var toret = [],
97             document = this.document;
98         this._$.contents().each(function() {
99             if(this.nodeType === Node.ELEMENT_NODE) {
100                 toret.push(document.createElementNode(this));
101             }
102             else if(this.nodeType === Node.TEXT_NODE) {
103                 toret.push(document.createTextNode(this));
104             }
105         });
106         return toret;
107     },
108
109     indexOf: function(node) {
110         return this._$.contents().index(node._$);
111     },
112
113     setTag: function(tagName) {
114         var node = this.document.createElementNode({tagName: tagName}),
115             oldTagName = this.getTagName(),
116             myContents = this._$.contents();
117
118         this.getAttrs().forEach(function(attribute) {
119             node.setAttr(attribute.name, attribute.value, true);
120         });
121         node.setData(this.getData());
122
123         if(this.sameNode(this.document.root)) {
124             defineDocumentProperties(this.document, node._$);
125         }
126         this._$.replaceWith(node._$);
127         this._setNativeNode(node._$[0]);
128         this._$.append(myContents);
129         this.triggerChangeEvent('nodeTagChange', {oldTagName: oldTagName, newTagName: this.getTagName()});
130     },
131
132     getAttr: function(name) {
133         return this._$.attr(name);
134     },
135
136     setAttr: function(name, value, silent) {
137         var oldVal = this.getAttr(name);
138         this._$.attr(name, value);
139         if(!silent) {
140             this.triggerChangeEvent('nodeAttrChange', {attr: name, oldVal: oldVal, newVal: value});
141         }
142     },
143
144     getAttrs: function() {
145         var toret = [];
146         for(var i = 0; i < this.nativeNode.attributes.length; i++) {
147             toret.push(this.nativeNode.attributes[i]);
148         }
149         return toret;
150     },
151
152     append: function(node) {
153         node = node instanceof DocumentNode ? node : this.document.createElementNode(node);
154         this._$.append(node.nativeNode);
155     },
156
157     unwrapContent: function() {
158         var parent = this.parent();
159         if(!parent) {
160             return;
161         }
162
163         var parentContents = parent.contents(),
164             myContents = this.contents(),
165             myIdx = parent.indexOf(this);
166
167         if(myContents.length === 0) {
168             return this.detach();
169         }
170
171         var moveLeftRange, moveRightRange, leftMerged;
172
173         if(myIdx > 0 && (parentContents[myIdx-1].nodeType === TEXT_NODE) && (myContents[0].nodeType === TEXT_NODE)) {
174             parentContents[myIdx-1].appendText(myContents[0].getText());
175             myContents[0].detach();
176             moveLeftRange = true;
177             leftMerged = true;
178         } else {
179             leftMerged = false;
180         }
181
182         if(!(leftMerged && myContents.length === 1)) {
183             if(myIdx < parentContents.length - 1 && (parentContents[myIdx+1].nodeType === TEXT_NODE) && (myContents[myContents.length-1].nodeType === TEXT_NODE)) {
184                 parentContents[myIdx+1].prependText(myContents[myContents.length-1].getText());
185                 myContents[myContents.length-1].detach();
186                 moveRightRange = true;
187             }
188         }
189
190         var childrenLength = this.contents().length;
191         this.contents().forEach(function(child) {
192             this.before(child);
193         }.bind(this));
194
195         this.detach();
196
197         return {
198             element1: parent.contents()[myIdx + (moveLeftRange ? -1 : 0)],
199             element2: parent.contents()[myIdx + childrenLength-1 + (moveRightRange ? 1 : 0)]
200         };
201     },
202
203     wrapText: function(params) {
204         return this.document._wrapText(_.extend({inside: this}, params));
205     },
206
207     toXML: function() {
208         var wrapper = $('<div>');
209         wrapper.append(this._getXMLDOMToDump());
210         return wrapper.html();
211     },
212     
213     _getXMLDOMToDump: function() {
214         return this._$;
215     }
216 });
217
218 var TextNode = function(nativeNode, document) {
219     DocumentNode.call(this, nativeNode, document);
220 };
221 TextNode.prototype = Object.create(DocumentNode.prototype);
222
223 $.extend(TextNode.prototype, {
224     nodeType: Node.TEXT_NODE,
225
226     getText: function() {
227         return this.nativeNode.data;
228     },
229
230     setText: function(text) {
231         this.nativeNode.data = text;
232         this.triggerTextChangeEvent();
233     },
234
235     appendText: function(text) {
236         this.nativeNode.data = this.nativeNode.data + text;
237         this.triggerTextChangeEvent();
238     },
239
240     prependText: function(text) {
241         this.nativeNode.data = text + this.nativeNode.data;
242         this.triggerTextChangeEvent();
243     },
244
245     wrapWith: function(desc) {
246         if(typeof desc.start === 'number' && typeof desc.end === 'number') {
247             return this.document._wrapText({
248                 inside: this.parent(),
249                 textNodeIdx: this.parent().indexOf(this),
250                 offsetStart: Math.min(desc.start, desc.end),
251                 offsetEnd: Math.max(desc.start, desc.end),
252                 _with: {tag: desc.tagName, attrs: desc.attrs}
253             });
254         } else {
255             return DocumentNode.prototype.wrapWith.call(this, desc);
256         }
257     },
258
259     triggerTextChangeEvent: function() {
260         var event = new events.ChangeEvent('nodeTextChange', {node: this});
261         this.document.trigger('change', event);
262     }
263 });
264
265
266 var parseXML = function(xml) {
267     return $(xml)[0];
268 };
269
270 var Document = function(xml) {
271     this.loadXML(xml);
272 };
273
274 $.extend(Document.prototype, Backbone.Events, {
275     ElementNodeFactory: ElementNode,
276     TextNodeFactory: TextNode,
277
278     createElementNode: function(from) {
279         if(!(from instanceof HTMLElement)) {
280             if(from.text) {
281                 from = document.createTextNode(from.text);
282             } else {
283                 var node = $('<' + from.tagName + '>');
284
285                 _.keys(from.attrs || {}).forEach(function(key) {
286                     node.attr(key, from.attrs[key]);
287                 });
288
289                 from = node[0];
290             }
291         }
292         return new this.ElementNodeFactory(from, this);
293     },
294
295     createTextNode: function(nativeNode) {
296         return new this.TextNodeFactory(nativeNode, this);
297     },
298
299     loadXML: function(xml, options) {
300         options = options || {};
301         defineDocumentProperties(this, $(parseXML(xml)));
302         if(!options.silent) {
303             this.trigger('contentSet');
304         }
305     },
306
307     toXML: function() {
308         return this.root.toXML();
309     },
310
311     _wrapText: function(params) {
312         params = _.extend({textNodeIdx: 0}, params);
313         if(typeof params.textNodeIdx === 'number') {
314             params.textNodeIdx = [params.textNodeIdx];
315         }
316         
317         var contentsInside = params.inside.contents(),
318             idx1 = Math.min.apply(Math, params.textNodeIdx),
319             idx2 = Math.max.apply(Math, params.textNodeIdx),
320             textNode1 = contentsInside[idx1],
321             textNode2 = contentsInside[idx2],
322             sameNode = textNode1.sameNode(textNode2),
323             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
324             prefixInside = textNode1.getText().substr(params.offsetStart),
325             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
326             suffixOutside = textNode2.getText().substr(params.offsetEnd)
327         ;
328
329         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
330             throw new Error('Wrapping text in non-sibling text nodes not supported.');
331         }
332         
333         var wrapperElement = this.createElementNode({tagName: params._with.tag, attrs: params._with.attrs});
334         textNode1.after(wrapperElement);
335         textNode1.detach();
336         
337         if(prefixOutside.length > 0) {
338             wrapperElement.before({text:prefixOutside});
339         }
340         if(sameNode) {
341             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
342             wrapperElement.append({text: core});
343         } else {
344             textNode2.detach();
345             if(prefixInside.length > 0) {
346                 wrapperElement.append({text: prefixInside});
347             }
348             for(var i = idx1 + 1; i < idx2; i++) {
349                 wrapperElement.append(contentsInside[i]);
350             }
351             if(suffixInside.length > 0) {
352                 wrapperElement.append({text: suffixInside});
353             }
354         }
355         if(suffixOutside.length > 0) {
356             wrapperElement.after({text: suffixOutside});
357         }
358         return wrapperElement;
359     }
360 });
361
362 var defineDocumentProperties = function(doc, $document) {
363     Object.defineProperty(doc, 'root', {get: function() {
364         return doc.createElementNode($document[0]);
365     }, configurable: true});
366     Object.defineProperty(doc, 'dom', {get: function() {
367         return $document[0];
368     }, configurable: true});
369 };
370
371 return {
372     documentFromXML: function(xml) {
373         return new Document(parseXML(xml));
374     },
375
376     elementNodeFromXML: function(xml) {
377         return this.documentFromXML(xml).root;
378     },
379
380     Document: Document,
381     DocumentNode: DocumentNode,
382     ElementNode: ElementNode
383 };
384
385 });