smartxml: Throw an error on invalid argument to replaceWith method
[fnpeditor.git] / src / smartxml / core.js
1 define(function(require) {
2     
3 'use strict';
4 /* globals Node */
5
6 var _ = require('libs/underscore');
7
8
9 var INSERTION = function(implementation) {
10     var toret = function(node, options) {
11         var insertion = this.getNodeInsertion(node),
12             nodeWasContained = this.document.containsNode(insertion.ofNode),
13             nodeParent,
14             returned;
15         options = options || {};
16         if(!(this.document.containsNode(this)) || !insertion.isNew) {
17             nodeParent = insertion.ofNode.parent();
18         }
19         returned = implementation.call(this, insertion.ofNode);
20         if(!options.silent && returned.sameNode(insertion.ofNode)) {
21             this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode}, nodeParent, nodeWasContained);
22         }
23         return returned;
24     };
25     return toret;
26 };
27
28 var documentNodeTransformations = {
29     detach: function() {
30         var parent = this.parent(),
31             existed = this.document.containsNode(this);
32         this._$.detach();
33         if(existed) {
34             this.triggerChangeEvent('nodeDetached', {parent: parent});
35             if(!parent) {
36                 // This was the root of the document
37                 this.document._defineDocumentProperties(null);
38             }
39         }
40         return this;
41     },
42
43     replaceWith: function(node) {
44         var toret;
45         if(this.isRoot()) {
46             return this.document.replaceRoot(node);
47         }
48         if(this.parent()) {
49             toret = this.after(node);
50             this.detach();
51             return toret;
52         }
53         throw new Error('Cannot replace node without a parent.');
54     },
55
56     after: INSERTION(function(node) {
57         var next = this.next();
58         if(next && next.nodeType === Node.TEXT_NODE && node.nodeType === Node.TEXT_NODE) {
59             next.setText(node.getText() + next.getText());
60             node.detach();
61             return next;
62         }
63         this._$.after(node.nativeNode);
64         return node;
65     }),
66
67     before: INSERTION(function(node) {
68         var prev = this.prev();
69         if(prev && prev.nodeType === Node.TEXT_NODE && node.nodeType === Node.TEXT_NODE) {
70             prev.setText(prev.getText() + node.getText());
71             node.detach();
72             return prev;
73         }
74         this._$.before(node.nativeNode);
75         return node;
76     }),
77
78     wrapWith: function(node) {
79         var insertion = this.getNodeInsertion(node);
80         if(this.parent()) {
81             this.before(insertion.ofNode);
82         }
83         insertion.ofNode.append(this);
84         return insertion.ofNode;
85     },
86
87     /**
88     * Removes parent of a node if node has no siblings.
89     */
90     unwrap: function() {
91         if(this.isRoot()) {
92             return;
93         }
94         var parent = this.parent(),
95             grandParent;
96         if(parent.contents().length === 1) {
97             grandParent = parent.parent();
98             parent.unwrapContent();
99             return grandParent;
100         }
101     }
102 };
103
104 var elementNodeTransformations = {
105
106     detach: function() {
107         var next;
108         if(this.parent() && this.isSurroundedByTextElements()) {
109             next = this.next();
110             this.prev().appendText(next.getText());
111             next.detach();
112         }
113         return this.__super__.detach();
114     },
115
116     setTag: function(tagName) {
117         var node = this.document.createDocumentNode({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             this.document._defineDocumentProperties(node._$);
128         }
129
130         /* TODO: This invalidates old references to this node. Caching instances on nodes would fix this. */
131         this._$.replaceWith(node._$);
132         this._setNativeNode(node._$[0]);
133         this._$.append(myContents);
134         this.triggerChangeEvent('nodeTagChange', {oldTagName: oldTagName, newTagName: this.getTagName()});
135     },
136
137
138     setAttr: function(name, value, silent) {
139         var oldVal = this.getAttr(name);
140         this._$.attr(name, value);
141         if(!silent) {
142             this.triggerChangeEvent('nodeAttrChange', {attr: name, oldVal: oldVal, newVal: value});
143         }
144     },
145
146     append: INSERTION(function(node) {
147         var last = _.last(this.contents());
148         if(last && last.nodeType === Node.TEXT_NODE && node.nodeType === Node.TEXT_NODE) {
149             last.setText(last.getText() + node.getText());
150             node.detach();
151             return last;
152         } else {
153             this._$.append(node.nativeNode);
154             return node;
155         }
156     }),
157
158     prepend: INSERTION(function(node) {
159         var first = this.contents()[0];
160         if(first && first.nodeType === Node.TEXT_NODE && node.nodeType === Node.TEXT_NODE) {
161             first.setText(node.getText() + first.getText());
162             node.detach();
163             return first;
164         } else {
165             this._$.prepend(node.nativeNode);
166             return node;
167         }
168     }),
169
170     insertAtIndex: function(nativeNode, index) {
171         var contents = this.contents();
172         if(index < contents.length) {
173             return contents[index].before(nativeNode);
174         } else if(index === contents.length) {
175             return this.append(nativeNode);
176         }
177     },
178
179     unwrapContent: function() {
180         var parent = this.parent();
181         if(!parent) {
182             return;
183         }
184
185         var myContents = this.contents(),
186             myIdx = parent.indexOf(this);
187
188
189         if(myContents.length === 0) {
190             return this.detach();
191         }
192
193
194         var childrenLength = this.contents().length,
195             first = true,
196             shiftRange = false;
197         this.contents().forEach(function(child) {
198             var returned = this.before(child);
199             if(first && !(returned.sameNode(child))) {
200                 shiftRange = true;
201                 first = false;
202             }
203         }.bind(this));
204
205         this.detach();
206
207         return {
208             element1: parent.contents()[myIdx + (shiftRange ? -1 : 0)],
209             element2: parent.contents()[myIdx + childrenLength-1 + (shiftRange ? -1 : 0)]
210         };
211     },
212
213     wrapText: function(params) {
214         return this.document._wrapText(_.extend({inside: this}, params));
215     }
216 };
217
218 var textNodeTransformations = {
219     setText: {
220         impl: function(t, text) {
221             t.oldText = this.getText();
222             this.nativeNode.data = text;
223             this.triggerTextChangeEvent();
224         },
225         undo: function(t) {
226             this.setText(t.oldText);
227         }
228     },
229
230     before: INSERTION(function(node) {
231         if(node.nodeType === Node.TEXT_NODE) {
232             this.prependText(node.getText());
233             node.detach();
234             return this;
235         } else {
236             return this.__super__.before(node, {silent:true});
237         }
238     }),
239
240     after: INSERTION(function(node) {
241         if(node.nodeType === Node.TEXT_NODE) {
242             this.appendText(node.getText());
243             node.detach();
244             return this;
245         } else {
246             return this.__super__.after(node, {silent:true});
247         }
248     }),
249
250     append: function(node) {
251         if(node.nodeType === Node.TEXT_NODE) {
252             this.appendText(node.getText());
253             node.detach();
254             return this;
255         }
256     },
257     prepend: function(node) {
258         if(node.nodeType === Node.TEXT_NODE) {
259             this.prependText(node.getText());
260             node.detach();
261             return this;
262         }
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 this.__super__.wrapWith.call(this, desc);
286         }
287     },
288
289     split: function(params) {
290         var parentElement = this.parent(),
291             passed = false,
292             succeedingChildren = [],
293             prefix = this.getText().substr(0, params.offset),
294             suffix = this.getText().substr(params.offset);
295
296         parentElement.contents().forEach(function(child) {
297             if(passed) {
298                 succeedingChildren.push(child);
299             }
300             if(child.sameNode(this)) {
301                 passed = true;
302             }
303         }.bind(this));
304
305         if(prefix.length > 0) {
306             this.setText(prefix);
307         }
308         else {
309             this.detach();
310         }
311
312         var attrs = {};
313         parentElement.getAttrs().forEach(function(attr) {attrs[attr.name] = attr.value; });
314         var newElement = this.document.createDocumentNode({tagName: parentElement.getTagName(), attrs: attrs});
315         parentElement.after(newElement);
316
317         if(suffix.length > 0) {
318             newElement.append({text: suffix});
319         }
320         succeedingChildren.forEach(function(child) {
321             newElement.append(child);
322         });
323
324         return {first: parentElement, second: newElement};
325     },
326
327     divideWithElementNode: function(node, params) {
328         var insertion = this.getNodeInsertion(node),
329             myText = this.getText();
330
331         if(params.offset === myText.length) {
332             return this.after(node);
333         }
334         if(params.offset === 0) {
335             return this.before(node);
336         }
337
338         var lhsText = myText.substr(0, params.offset),
339             rhsText = myText.substr(params.offset),
340             rhsTextNode = this.document.createDocumentNode({text: rhsText});
341
342         this.setText(lhsText);
343         this.after(insertion.ofNode);
344         insertion.ofNode.after(rhsTextNode);
345         return insertion.ofNode;
346     }
347 };
348
349 var documentTransformations = {
350     wrapNodes: function(params) {
351         if(!(params.node1.parent().sameNode(params.node2.parent()))) {
352             throw new Error('Wrapping non-sibling nodes not supported.');
353         }
354
355         var parent = params.node1.parent(),
356             parentContents = parent.contents(),
357             wrapper = this.createDocumentNode({
358                 tagName: params._with.tagName,
359                 attrs: params._with.attrs}),
360             idx1 = parent.indexOf(params.node1),
361             idx2 = parent.indexOf(params.node2);
362
363         if(idx1 > idx2) {
364             var tmp = idx1;
365             idx1 = idx2;
366             idx2 = tmp;
367         }
368
369         var insertingMethod, insertingTarget;
370         if(idx1 === 0) {
371             insertingMethod = 'prepend';
372             insertingTarget = parent;
373         } else {
374             insertingMethod = 'after';
375             insertingTarget = parentContents[idx1-1];
376         }
377
378         for(var i = idx1; i <= idx2; i++) {
379             wrapper.append(parentContents[i].detach());
380         }
381
382         insertingTarget[insertingMethod](wrapper);
383         return wrapper;
384     },
385
386     _wrapText: function(params) {
387         params = _.extend({textNodeIdx: 0}, params);
388         if(typeof params.textNodeIdx === 'number') {
389             params.textNodeIdx = [params.textNodeIdx];
390         }
391         
392         var contentsInside = params.inside.contents(),
393             idx1 = Math.min.apply(Math, params.textNodeIdx),
394             idx2 = Math.max.apply(Math, params.textNodeIdx),
395             textNode1 = contentsInside[idx1],
396             textNode2 = contentsInside[idx2],
397             sameNode = textNode1.sameNode(textNode2),
398             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
399             prefixInside = textNode1.getText().substr(params.offsetStart),
400             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
401             suffixOutside = textNode2.getText().substr(params.offsetEnd)
402         ;
403
404         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
405             throw new Error('Wrapping text in non-sibling text nodes not supported.');
406         }
407         
408         var wrapperElement = this.createDocumentNode({tagName: params._with.tagName, attrs: params._with.attrs});
409         textNode1.after(wrapperElement);
410         textNode1.detach();
411         
412         if(prefixOutside.length > 0) {
413             wrapperElement.before({text:prefixOutside});
414         }
415         if(sameNode) {
416             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
417             wrapperElement.append({text: core});
418         } else {
419             textNode2.detach();
420             if(prefixInside.length > 0) {
421                 wrapperElement.append({text: prefixInside});
422             }
423             for(var i = idx1 + 1; i < idx2; i++) {
424                 wrapperElement.append(contentsInside[i]);
425             }
426             if(suffixInside.length > 0) {
427                 wrapperElement.append({text: suffixInside});
428             }
429         }
430         if(suffixOutside.length > 0) {
431             wrapperElement.after({text: suffixOutside});
432         }
433         return wrapperElement;
434     },
435     replaceRoot: function(node) {
436         var insertion = this.getNodeInsertion(node);
437         this.root.detach();
438         this._defineDocumentProperties(insertion.ofNode._$);
439         insertion.ofNode.triggerChangeEvent('nodeAdded');
440         return insertion.ofNode;
441     },
442     deleteText: function(params) {
443         var ptr, next, toDetach, middle, text;
444
445         if(params.from.node.sameNode(params.to.node)) {
446             ptr = params.from.node;
447             text = ptr.getText();
448             ptr.setText(text.substr(0, params.from.offset) + text.substr(params.to.offset));
449             return;
450         }
451
452         // Both edge text nodes need to be edited before anything else happen in case that
453         // they get merged when detaching content between them.
454         params.from.node.setText(params.from.node.getText().substr(0, params.from.offset));
455         params.to.node.setText(params.to.node.getText().substr(params.to.offset));
456
457         ptr = params.from.node;
458         next = ptr.next();
459
460         while(next || ptr.parent()) {
461             if(next) {
462                 if(next.sameNode(params.to.node)) {
463                     return;
464                 }
465                 else if(next.nodeType === Node.ELEMENT_NODE && next.containsNode(params.to.node)) {
466                     middle = next;
467                     break;
468                 } else {
469                     toDetach = next;
470                     next = next.next();
471                     toDetach.detach();
472                 }
473             } else {
474                 ptr = ptr.parent();
475                 next = ptr.next();
476             }
477         }
478
479         if(!this.containsNode(params.to.node)) {
480             // The end node was merged during detaching nodes above - there is nothing more left to do.
481             return;
482         }
483
484         ptr = middle.contents()[0];
485         while(ptr && !ptr.sameNode(params.to.node)) {
486             if(ptr.nodeType === Node.ELEMENT_NODE && ptr.containsNode(params.to.node)) {
487                 ptr = ptr.contents()[0];
488                 continue;
489             } else {
490                 ptr = ptr.next();
491                 ptr.prev().detach();
492             }
493         }
494     }
495 };
496
497 return {
498     document: {
499         transformations: documentTransformations
500     },
501     documentNode: {
502         transformations: documentNodeTransformations
503     },
504     elementNode: {
505         transformations: elementNodeTransformations
506     },
507     textNode: {
508         transformations: textNodeTransformations
509     }
510 };
511
512 });