smartxml: ElementNode.prepend
[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     prepend: function(node) {
158         node = node instanceof DocumentNode ? node : this.document.createElementNode(node);
159         this._$.prepend(node.nativeNode);
160     },
161
162     unwrapContent: function() {
163         var parent = this.parent();
164         if(!parent) {
165             return;
166         }
167
168         var parentContents = parent.contents(),
169             myContents = this.contents(),
170             myIdx = parent.indexOf(this);
171
172         if(myContents.length === 0) {
173             return this.detach();
174         }
175
176         var moveLeftRange, moveRightRange, leftMerged;
177
178         if(myIdx > 0 && (parentContents[myIdx-1].nodeType === TEXT_NODE) && (myContents[0].nodeType === TEXT_NODE)) {
179             parentContents[myIdx-1].appendText(myContents[0].getText());
180             myContents[0].detach();
181             moveLeftRange = true;
182             leftMerged = true;
183         } else {
184             leftMerged = false;
185         }
186
187         if(!(leftMerged && myContents.length === 1)) {
188             if(myIdx < parentContents.length - 1 && (parentContents[myIdx+1].nodeType === TEXT_NODE) && (myContents[myContents.length-1].nodeType === TEXT_NODE)) {
189                 parentContents[myIdx+1].prependText(myContents[myContents.length-1].getText());
190                 myContents[myContents.length-1].detach();
191                 moveRightRange = true;
192             }
193         }
194
195         var childrenLength = this.contents().length;
196         this.contents().forEach(function(child) {
197             this.before(child);
198         }.bind(this));
199
200         this.detach();
201
202         return {
203             element1: parent.contents()[myIdx + (moveLeftRange ? -1 : 0)],
204             element2: parent.contents()[myIdx + childrenLength-1 + (moveRightRange ? 1 : 0)]
205         };
206     },
207
208     wrapText: function(params) {
209         return this.document._wrapText(_.extend({inside: this}, params));
210     },
211
212     toXML: function() {
213         var wrapper = $('<div>');
214         wrapper.append(this._getXMLDOMToDump());
215         return wrapper.html();
216     },
217     
218     _getXMLDOMToDump: function() {
219         return this._$;
220     }
221 });
222
223 var TextNode = function(nativeNode, document) {
224     DocumentNode.call(this, nativeNode, document);
225 };
226 TextNode.prototype = Object.create(DocumentNode.prototype);
227
228 $.extend(TextNode.prototype, {
229     nodeType: Node.TEXT_NODE,
230
231     getText: function() {
232         return this.nativeNode.data;
233     },
234
235     setText: function(text) {
236         this.nativeNode.data = text;
237         this.triggerTextChangeEvent();
238     },
239
240     appendText: function(text) {
241         this.nativeNode.data = this.nativeNode.data + text;
242         this.triggerTextChangeEvent();
243     },
244
245     prependText: function(text) {
246         this.nativeNode.data = text + this.nativeNode.data;
247         this.triggerTextChangeEvent();
248     },
249
250     wrapWith: function(desc) {
251         if(typeof desc.start === 'number' && typeof desc.end === 'number') {
252             return this.document._wrapText({
253                 inside: this.parent(),
254                 textNodeIdx: this.parent().indexOf(this),
255                 offsetStart: Math.min(desc.start, desc.end),
256                 offsetEnd: Math.max(desc.start, desc.end),
257                 _with: {tag: desc.tagName, attrs: desc.attrs}
258             });
259         } else {
260             return DocumentNode.prototype.wrapWith.call(this, desc);
261         }
262     },
263
264     triggerTextChangeEvent: function() {
265         var event = new events.ChangeEvent('nodeTextChange', {node: this});
266         this.document.trigger('change', event);
267     }
268 });
269
270
271 var parseXML = function(xml) {
272     return $(xml)[0];
273 };
274
275 var Document = function(xml) {
276     this.loadXML(xml);
277 };
278
279 $.extend(Document.prototype, Backbone.Events, {
280     ElementNodeFactory: ElementNode,
281     TextNodeFactory: TextNode,
282
283     createElementNode: function(from) {
284         if(!(from instanceof HTMLElement)) {
285             if(from.text) {
286                 from = document.createTextNode(from.text);
287             } else {
288                 var node = $('<' + from.tagName + '>');
289
290                 _.keys(from.attrs || {}).forEach(function(key) {
291                     node.attr(key, from.attrs[key]);
292                 });
293
294                 from = node[0];
295             }
296         }
297         return new this.ElementNodeFactory(from, this);
298     },
299
300     createTextNode: function(nativeNode) {
301         return new this.TextNodeFactory(nativeNode, this);
302     },
303
304     loadXML: function(xml, options) {
305         options = options || {};
306         defineDocumentProperties(this, $(parseXML(xml)));
307         if(!options.silent) {
308             this.trigger('contentSet');
309         }
310     },
311
312     toXML: function() {
313         return this.root.toXML();
314     },
315
316     _wrapText: function(params) {
317         params = _.extend({textNodeIdx: 0}, params);
318         if(typeof params.textNodeIdx === 'number') {
319             params.textNodeIdx = [params.textNodeIdx];
320         }
321         
322         var contentsInside = params.inside.contents(),
323             idx1 = Math.min.apply(Math, params.textNodeIdx),
324             idx2 = Math.max.apply(Math, params.textNodeIdx),
325             textNode1 = contentsInside[idx1],
326             textNode2 = contentsInside[idx2],
327             sameNode = textNode1.sameNode(textNode2),
328             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
329             prefixInside = textNode1.getText().substr(params.offsetStart),
330             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
331             suffixOutside = textNode2.getText().substr(params.offsetEnd)
332         ;
333
334         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
335             throw new Error('Wrapping text in non-sibling text nodes not supported.');
336         }
337         
338         var wrapperElement = this.createElementNode({tagName: params._with.tag, attrs: params._with.attrs});
339         textNode1.after(wrapperElement);
340         textNode1.detach();
341         
342         if(prefixOutside.length > 0) {
343             wrapperElement.before({text:prefixOutside});
344         }
345         if(sameNode) {
346             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
347             wrapperElement.append({text: core});
348         } else {
349             textNode2.detach();
350             if(prefixInside.length > 0) {
351                 wrapperElement.append({text: prefixInside});
352             }
353             for(var i = idx1 + 1; i < idx2; i++) {
354                 wrapperElement.append(contentsInside[i]);
355             }
356             if(suffixInside.length > 0) {
357                 wrapperElement.append({text: suffixInside});
358             }
359         }
360         if(suffixOutside.length > 0) {
361             wrapperElement.after({text: suffixOutside});
362         }
363         return wrapperElement;
364     }
365 });
366
367 var defineDocumentProperties = function(doc, $document) {
368     Object.defineProperty(doc, 'root', {get: function() {
369         return doc.createElementNode($document[0]);
370     }, configurable: true});
371     Object.defineProperty(doc, 'dom', {get: function() {
372         return $document[0];
373     }, configurable: true});
374 };
375
376 return {
377     documentFromXML: function(xml) {
378         return new Document(parseXML(xml));
379     },
380
381     elementNodeFromXML: function(xml) {
382         return this.documentFromXML(xml).root;
383     },
384
385     Document: Document,
386     DocumentNode: DocumentNode,
387     ElementNode: ElementNode
388 };
389
390 });