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