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