wip: breakContent & others transformations - still need to confirm the api
[fnpeditor.git] / src / smartxml / smartxml.js
1 define([
2     'libs/jquery',
3     'libs/underscore',
4     'libs/backbone',
5     'smartxml/events',
6     'smartxml/transformations'
7 ], function($, _, Backbone, events, transformations) {
8     
9 'use strict';
10 /* globals Node */
11
12 var TEXT_NODE = Node.TEXT_NODE;
13
14
15 var INSERTION = function(implementation) {
16     var toret = function(node) {
17         var insertion = this.getNodeInsertion(node),
18             nodeParent;
19         if(!(this.document.containsNode(this))) {
20             nodeParent = insertion.ofNode.parent();
21         }
22         implementation.call(this, insertion.ofNode.nativeNode);
23         this.triggerChangeEvent(insertion.insertsNew ? 'nodeAdded' : 'nodeMoved', {node: insertion.ofNode}, nodeParent);
24         return insertion.ofNode;
25     };
26     return toret;
27 };
28
29 var DocumentNode = function(nativeNode, document) {
30     if(!document) {
31         throw new Error('undefined document for a node');
32     }
33     this.document = document;
34     this._setNativeNode(nativeNode);
35
36 };
37
38 $.extend(DocumentNode.prototype, {
39
40     transformations: new transformations.TransformationStorage(),
41
42     transform: function(name, args) {
43         var Transformation = this.transformations.get(name),
44             transformation;
45         if(Transformation) {
46             transformation = new Transformation(this.document, this, args);
47         }
48         return this.document.transform(transformation);
49     },
50
51     _setNativeNode: function(nativeNode) {
52         this.nativeNode = nativeNode;
53         this._$ = $(nativeNode);
54     },
55
56     clone: function() {
57         var clone = this._$.clone(true, true);
58         // clone.find('*').addBack().each(function() {
59         //     var n = $(this);
60         //     if(n.data('canvasElement')) {
61         //         n.data('canvasElement', $.extend(true, {}, n.data('canvasElement')));
62         //         n.data('canvasElement').$element = n.data('canvasElement').$element.clone(true, true);
63         //     }
64         // });
65         return this.document.createDocumentNode(clone[0]);
66     },
67
68     getPath: function(ancestor) {
69         var nodePath = [this].concat(this.parents()),
70             toret, idx;
71         ancestor = ancestor || this.document.root;
72
73         nodePath.some(function(node, i) {
74             if(node.sameNode(ancestor)) {
75                 idx = i;
76                 return true;
77             }
78         });
79
80         if(idx !== 'undefined') {
81             nodePath = nodePath.slice(0, idx);
82         }
83         toret = nodePath.map(function(node) {return node.getIndex(); });
84         toret.reverse();
85         return toret;
86     },
87
88     isRoot: function() {
89         return this.document.root.sameNode(this);
90     },
91
92     detach: function() {
93         var parent = this.parent();
94         this._$.detach();
95         this.triggerChangeEvent('nodeDetached', {parent: parent});
96         return this;
97     },
98
99     replaceWith: function(node) {
100         var toret;
101         if(this.isRoot()) {
102             return this.document.replaceRoot(node);
103         }
104         toret = this.after(node);
105         this.detach();
106         return toret;
107     },
108
109     sameNode: function(otherNode) {
110         return !!(otherNode) && this.nativeNode === otherNode.nativeNode;
111     },
112
113     parent: function() {
114         var parentNode = this.nativeNode.parentNode;
115         if(parentNode && parentNode.nodeType === Node.ELEMENT_NODE) {
116             return this.document.createDocumentNode(parentNode);
117         }
118         return null;
119     },
120
121     parents: function() {
122         var parent = this.parent(),
123             parents = parent ? parent.parents() : [];
124         if(parent) {
125             parents.unshift(parent);
126         }
127         return parents;
128     },
129
130     prev: function() {
131         var myIdx = this.getIndex();
132         return myIdx > 0 ? this.parent().contents()[myIdx-1] : null;
133     },
134
135     next: function() {
136         if(this.isRoot()) {
137             return null;
138         }
139         var myIdx = this.getIndex(),
140             parentContents = this.parent().contents();
141         return myIdx < parentContents.length - 1 ? parentContents[myIdx+1] : null;
142     },
143
144     isSurroundedByTextElements: function() {
145         var prev = this.prev(),
146             next = this.next();
147         return prev && (prev.nodeType === Node.TEXT_NODE) && next && (next.nodeType === Node.TEXT_NODE);
148     },
149
150     after: INSERTION(function(nativeNode) {
151         return this._$.after(nativeNode);
152     }),
153
154     before: INSERTION(function(nativeNode) {
155         return this._$.before(nativeNode);
156     }),
157
158     wrapWith: function(node) {
159         var insertion = this.getNodeInsertion(node);
160         if(this.parent()) {
161             this.before(insertion.ofNode);
162         }
163         insertion.ofNode.append(this);
164         return insertion.ofNode;
165     },
166
167     /**
168     * Removes parent of a node if node has no siblings.
169     */
170     unwrap: function() {
171         if(this.isRoot()) {
172             return;
173         }
174         var parent = this.parent(),
175             grandParent;
176         if(parent.contents().length === 1) {
177             grandParent = parent.parent();
178             parent.unwrapContent();
179             return grandParent;
180         }
181     },
182
183     triggerChangeEvent: function(type, metaData, origParent) {
184         var node = (metaData && metaData.node) ? metaData.node : this,
185             event = new events.ChangeEvent(type, $.extend({node: node}, metaData || {}));
186         if(type === 'nodeDetached' || this.document.containsNode(event.meta.node)) {
187             this.document.trigger('change', event);
188         }
189         if((type === 'nodeAdded' || type === 'nodeMoved') && !(this.document.containsNode(this))) {
190              event = new events.ChangeEvent('nodeDetached', {node: node, parent: origParent});
191              this.document.trigger('change', event);
192         }
193     },
194     
195     getNodeInsertion: function(node) {
196         return this.document.getNodeInsertion(node);
197     },
198
199     getIndex: function() {
200         if(this.isRoot()) {
201             return 0;
202         }
203         return this.parent().indexOf(this);
204     }
205 });
206
207 var ElementNode = function(nativeNode, document) {
208     DocumentNode.call(this, nativeNode, document);
209 };
210 ElementNode.prototype = Object.create(DocumentNode.prototype);
211
212 $.extend(ElementNode.prototype, {
213     nodeType: Node.ELEMENT_NODE,
214
215     detach: function() {
216         var next;
217         if(this.parent() && this.isSurroundedByTextElements()) {
218             next = this.next();
219             this.prev().appendText(next.getText());
220             next.detach();
221         }
222         return DocumentNode.prototype.detach.call(this);
223     },
224
225     setData: function(key, value) {
226         if(value !== undefined) {
227             this._$.data(key, value);
228         } else {
229             this._$.removeData(_.keys(this._$.data()));
230             this._$.data(key);
231         }
232     },
233
234     getData: function(key) {
235         if(key) {
236             return this._$.data(key);
237         }
238         return this._$.data();
239     },
240
241     getTagName: function() {
242         return this.nativeNode.tagName.toLowerCase();
243     },
244
245     contents: function() {
246         var toret = [],
247             document = this.document;
248         this._$.contents().each(function() {
249             toret.push(document.createDocumentNode(this));
250         });
251         return toret;
252     },
253
254     indexOf: function(node) {
255         return this._$.contents().index(node._$);
256     },
257
258     setTag: function(tagName) {
259         var node = this.document.createDocumentNode({tagName: tagName}),
260             oldTagName = this.getTagName(),
261             myContents = this._$.contents();
262
263         this.getAttrs().forEach(function(attribute) {
264             node.setAttr(attribute.name, attribute.value, true);
265         });
266         node.setData(this.getData());
267
268         if(this.sameNode(this.document.root)) {
269             defineDocumentProperties(this.document, node._$);
270         }
271         this._$.replaceWith(node._$);
272         this._setNativeNode(node._$[0]);
273         this._$.append(myContents);
274         this.triggerChangeEvent('nodeTagChange', {oldTagName: oldTagName, newTagName: this.getTagName()});
275     },
276
277     getAttr: function(name) {
278         return this._$.attr(name);
279     },
280
281     setAttr: function(name, value, silent) {
282         var oldVal = this.getAttr(name);
283         this._$.attr(name, value);
284         if(!silent) {
285             this.triggerChangeEvent('nodeAttrChange', {attr: name, oldVal: oldVal, newVal: value});
286         }
287     },
288
289     getAttrs: function() {
290         var toret = [];
291         for(var i = 0; i < this.nativeNode.attributes.length; i++) {
292             toret.push(this.nativeNode.attributes[i]);
293         }
294         return toret;
295     },
296
297     append: INSERTION(function(nativeNode) {
298         this._$.append(nativeNode);
299     }),
300
301     prepend: INSERTION(function(nativeNode) {
302         this._$.prepend(nativeNode);
303     }),
304
305     insertAtIndex: function(nativeNode, index) {
306         var contents = this.contents();
307         if(index < contents.length) {
308             return contents[index].before(nativeNode);
309         } else if(index === contents.length) {
310             return this.append(nativeNode);
311         }
312     },
313
314     unwrapContent: function() {
315         var parent = this.parent();
316         if(!parent) {
317             return;
318         }
319
320         var myContents = this.contents(),
321             myIdx = parent.indexOf(this);
322
323
324         if(myContents.length === 0) {
325             return this.detach();
326         }
327
328         var prev = this.prev(),
329             next = this.next(),
330             moveLeftRange, moveRightRange, leftMerged;
331
332         if(prev && (prev.nodeType === TEXT_NODE) && (myContents[0].nodeType === TEXT_NODE)) {
333             prev.appendText(myContents[0].getText());
334             myContents[0].detach();
335             moveLeftRange = true;
336             leftMerged = true;
337         } else {
338             leftMerged = false;
339         }
340
341         if(!(leftMerged && myContents.length === 1)) {
342             var lastContents = _.last(myContents);
343             if(next && (next.nodeType === TEXT_NODE) && (lastContents.nodeType === TEXT_NODE)) {
344                 next.prependText(lastContents.getText());
345                 lastContents.detach();
346                 moveRightRange = true;
347             }
348         }
349
350         var childrenLength = this.contents().length;
351         this.contents().forEach(function(child) {
352             this.before(child);
353         }.bind(this));
354
355         this.detach();
356
357         return {
358             element1: parent.contents()[myIdx + (moveLeftRange ? -1 : 0)],
359             element2: parent.contents()[myIdx + childrenLength-1 + (moveRightRange ? 1 : 0)]
360         };
361     },
362
363     wrapText: function(params) {
364         return this.document._wrapText(_.extend({inside: this}, params));
365     },
366
367     toXML: function() {
368         var wrapper = $('<div>');
369         wrapper.append(this._getXMLDOMToDump());
370         return wrapper.html();
371     },
372     
373     _getXMLDOMToDump: function() {
374         return this._$;
375     }
376 });
377
378 // trans
379
380 // todo - split+append
381
382 ElementNode.prototype.transformations.register(transformations.createContextTransformation({
383     name: 'smartxml.setAttr',
384     impl: function(args) {
385         this.setAttr(args.name, args.value);
386     },
387     getChangeRoot: function() {
388         return this.context;
389     }
390 }));
391
392 DocumentNode.prototype.transformations.register(transformations.createContextTransformation({
393     name: 'smartxml.wrapWith',
394     getChangeRoot: function() {
395         return this.context.parent();
396     },
397     impl: function(args) {
398         return this.wrapWith(args);
399     }
400 }));
401
402 DocumentNode.prototype.transformations.register(transformations.createContextTransformation({
403     name: 'smartxml.wrapText',
404     getChangeRoot: function() {
405         return this.context;
406     },
407     impl: function(args) {
408         return this.wrapText(args);
409     }
410 }));
411
412 DocumentNode.prototype.transformations.register(transformations.createContextTransformation({
413     name: 'smartxml.detach',
414     getChangeRoot: function() {
415         return this.context.parent();
416     },
417     impl: function(args) {
418         return this.detach();
419     }
420 }));
421
422 ///
423
424 var TextNode = function(nativeNode, document) {
425     DocumentNode.call(this, nativeNode, document);
426 };
427 TextNode.prototype = Object.create(DocumentNode.prototype);
428
429 $.extend(TextNode.prototype, {
430     nodeType: Node.TEXT_NODE,
431
432     getText: function() {
433         return this.nativeNode.data;
434     },
435
436     setText: function(text) {
437         console.log('smartxml: ' + text);
438         this.nativeNode.data = text;
439         this.triggerTextChangeEvent();
440     },
441
442     appendText: function(text) {
443         this.nativeNode.data = this.nativeNode.data + text;
444         this.triggerTextChangeEvent();
445     },
446
447     prependText: function(text) {
448         this.nativeNode.data = text + this.nativeNode.data;
449         this.triggerTextChangeEvent();
450     },
451
452     wrapWith: function(desc) {
453         if(typeof desc.start === 'number' && typeof desc.end === 'number') {
454             return this.document._wrapText({
455                 inside: this.parent(),
456                 textNodeIdx: this.parent().indexOf(this),
457                 offsetStart: Math.min(desc.start, desc.end),
458                 offsetEnd: Math.max(desc.start, desc.end),
459                 _with: {tagName: desc.tagName, attrs: desc.attrs}
460             });
461         } else {
462             return DocumentNode.prototype.wrapWith.call(this, desc);
463         }
464     },
465
466     split: function(params) {
467         var parentElement = this.parent(),
468             passed = false,
469             succeedingChildren = [],
470             prefix = this.getText().substr(0, params.offset),
471             suffix = this.getText().substr(params.offset);
472
473         parentElement.contents().forEach(function(child) {
474             if(passed) {
475                 succeedingChildren.push(child);
476             }
477             if(child.sameNode(this)) {
478                 passed = true;
479             }
480         }.bind(this));
481
482         if(prefix.length > 0) {
483             this.setText(prefix);
484         }
485         else {
486             this.detach();
487         }
488
489         var attrs = {};
490         parentElement.getAttrs().forEach(function(attr) {attrs[attr.name] = attr.value; });
491         var newElement = this.document.createDocumentNode({tagName: parentElement.getTagName(), attrs: attrs});
492         parentElement.after(newElement);
493
494         if(suffix.length > 0) {
495             newElement.append({text: suffix});
496         }
497         succeedingChildren.forEach(function(child) {
498             newElement.append(child);
499         });
500
501         return {first: parentElement, second: newElement};
502     },
503
504     triggerTextChangeEvent: function() {
505         var event = new events.ChangeEvent('nodeTextChange', {node: this});
506         this.document.trigger('change', event);
507     }
508 });
509
510
511 TextNode.prototype.transformations.register(transformations.createContextTransformation({
512     name: 'rng.breakContent',
513     // impl: function(args) {
514     //     var node = this.context,
515     //         newNodes, emptyNode, emptyText;
516     //     newNodes = node.transform('smartxml.split', {offset: args.offset});
517     //     [newNodes.first, newNodes.second].some(function(newNode) {
518     //         if(!(newNode.contents().length)) {
519     //             newNode.transform('smartxml.append', {text: ''});
520     //             return true; // break
521     //         }
522     //     });
523     //     return _.extend(newNodes, {emptyText: emptyText});
524     // },
525     impl: function(args) {
526         var node = this,
527             newNodes, emptyNode, emptyText;
528         newNodes = node.split({offset: args.offset});
529         [newNodes.first, newNodes.second].some(function(newNode) {
530             if(!(newNode.contents().length)) {
531                 newNode.append({text: ''});
532                 return true; // break
533             }
534         });
535         return _.extend(newNodes, {emptyText: emptyText});
536     },
537     getChangeRoot: function() {
538         return this.context.parent().parent();
539     }
540 }));
541
542
543 ElementNode.prototype.transformations.register(transformations.createContextTransformation({
544     name: 'smartxml.setText',
545     impl: function(args) {
546         this.setText(args.text);
547     },
548     getChangeRoot: function() {
549         return this.context;
550     }
551 }));
552
553
554 var parseXML = function(xml) {
555     return $($.trim(xml))[0];
556 };
557
558 var Document = function(xml) {
559     this.loadXML(xml);
560     this.undoStack = [];
561     this.redoStack = [];
562 };
563
564 $.extend(Document.prototype, Backbone.Events, {
565     ElementNodeFactory: ElementNode,
566     TextNodeFactory: TextNode,
567     transformations: new transformations.TransformationStorage(),
568
569     createDocumentNode: function(from) {
570         if(!(from instanceof Node)) {
571             if(from.text !== undefined) {
572                 /* globals document */
573                 from = document.createTextNode(from.text);
574             } else {
575                 var node = $('<' + from.tagName + '>');
576
577                 _.keys(from.attrs || {}).forEach(function(key) {
578                     node.attr(key, from.attrs[key]);
579                 });
580
581                 from = node[0];
582             }
583         }
584         var Factory;
585         if(from.nodeType === Node.TEXT_NODE) {
586             Factory = this.TextNodeFactory;
587         } else if(from.nodeType === Node.ELEMENT_NODE) {
588             Factory = this.ElementNodeFactory;
589         }
590         return new Factory(from, this);
591     },
592
593     loadXML: function(xml, options) {
594         options = options || {};
595         defineDocumentProperties(this, $(parseXML(xml)));
596         if(!options.silent) {
597             this.trigger('contentSet');
598         }
599     },
600
601     toXML: function() {
602         return this.root.toXML();
603     },
604
605     containsNode: function(node) {
606         return this.root && (node.nativeNode === this.root.nativeNode || node._$.parents().index(this.root._$) !== -1);
607     },
608
609     wrapNodes: function(params) {
610         if(!(params.node1.parent().sameNode(params.node2.parent()))) {
611             throw new Error('Wrapping non-sibling nodes not supported.');
612         }
613
614         var parent = params.node1.parent(),
615             parentContents = parent.contents(),
616             wrapper = this.createDocumentNode({
617                 tagName: params._with.tagName,
618                 attrs: params._with.attrs}),
619             idx1 = parent.indexOf(params.node1),
620             idx2 = parent.indexOf(params.node2);
621
622         if(idx1 > idx2) {
623             var tmp = idx1;
624             idx1 = idx2;
625             idx2 = tmp;
626         }
627
628         var insertingMethod, insertingTarget;
629         if(idx1 === 0) {
630             insertingMethod = 'prepend';
631             insertingTarget = parent;
632         } else {
633             insertingMethod = 'after';
634             insertingTarget = parentContents[idx1-1];
635         }
636
637         for(var i = idx1; i <= idx2; i++) {
638             wrapper.append(parentContents[i].detach());
639         }
640
641         insertingTarget[insertingMethod](wrapper);
642         return wrapper;
643     },
644
645     getSiblingParents: function(params) {
646         var parents1 = [params.node1].concat(params.node1.parents()).reverse(),
647             parents2 = [params.node2].concat(params.node2.parents()).reverse(),
648             noSiblingParents = null;
649
650         if(parents1.length === 0 || parents2.length === 0 || !(parents1[0].sameNode(parents2[0]))) {
651             return noSiblingParents;
652         }
653
654         var i;
655         for(i = 0; i < Math.min(parents1.length, parents2.length); i++) {
656             if(parents1[i].sameNode(parents2[i])) {
657                 continue;
658             }
659             break;
660         }
661         return {node1: parents1[i], node2: parents2[i]};
662     },
663
664     _wrapText: function(params) {
665         params = _.extend({textNodeIdx: 0}, params);
666         if(typeof params.textNodeIdx === 'number') {
667             params.textNodeIdx = [params.textNodeIdx];
668         }
669         
670         var contentsInside = params.inside.contents(),
671             idx1 = Math.min.apply(Math, params.textNodeIdx),
672             idx2 = Math.max.apply(Math, params.textNodeIdx),
673             textNode1 = contentsInside[idx1],
674             textNode2 = contentsInside[idx2],
675             sameNode = textNode1.sameNode(textNode2),
676             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
677             prefixInside = textNode1.getText().substr(params.offsetStart),
678             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
679             suffixOutside = textNode2.getText().substr(params.offsetEnd)
680         ;
681
682         if(!(textNode1.parent().sameNode(textNode2.parent()))) {
683             throw new Error('Wrapping text in non-sibling text nodes not supported.');
684         }
685         
686         var wrapperElement = this.createDocumentNode({tagName: params._with.tagName, attrs: params._with.attrs});
687         textNode1.after(wrapperElement);
688         textNode1.detach();
689         
690         if(prefixOutside.length > 0) {
691             wrapperElement.before({text:prefixOutside});
692         }
693         if(sameNode) {
694             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
695             wrapperElement.append({text: core});
696         } else {
697             textNode2.detach();
698             if(prefixInside.length > 0) {
699                 wrapperElement.append({text: prefixInside});
700             }
701             for(var i = idx1 + 1; i < idx2; i++) {
702                 wrapperElement.append(contentsInside[i]);
703             }
704             if(suffixInside.length > 0) {
705                 wrapperElement.append({text: suffixInside});
706             }
707         }
708         if(suffixOutside.length > 0) {
709             wrapperElement.after({text: suffixOutside});
710         }
711         return wrapperElement;
712     },
713
714     trigger: function() {
715         //console.log('trigger: ' + arguments[0] + (arguments[1] ? ', ' + arguments[1].type : ''));
716         Backbone.Events.trigger.apply(this, arguments);
717     },
718
719     getNodeInsertion: function(node) {
720         var insertion = {};
721         if(node instanceof DocumentNode) {
722             insertion.ofNode = node;
723             insertion.insertsNew = !this.containsNode(node);
724         } else {
725           insertion.ofNode = this.createDocumentNode(node);
726           insertion.insertsNew = true;
727         }
728         return insertion;
729     },
730
731     replaceRoot: function(node) {
732         var insertion = this.getNodeInsertion(node);
733         this.root.detach();
734         defineDocumentProperties(this, insertion.ofNode._$);
735         insertion.ofNode.triggerChangeEvent('nodeAdded');
736         return insertion.ofNode;
737     },
738
739     transform: function(transformation, args) {
740         console.log('transform');
741         var Transformation, toret;
742         if(typeof transformation === 'string') {
743             Transformation = this.transformations.get(transformation);
744             if(Transformation) {
745                 transformation = new Transformation(this, this, args);
746             }
747         } 
748         if(transformation) {
749             toret = transformation.run();
750             this.undoStack.push(transformation);
751             console.log('clearing redo stack');
752             this.redoStack = [];
753             return toret;
754         } else {
755             throw new Error('Transformation ' + transformation + ' doesn\'t exist!');
756         }
757     },
758     undo: function() {
759         var transformation = this.undoStack.pop();
760         if(transformation) {
761             transformation.undo();
762             this.redoStack.push(transformation);
763         }
764     },
765     redo: function() {
766         var transformation = this.redoStack.pop();
767         if(transformation) {
768             transformation.run();
769             this.undoStack.push(transformation);
770         }
771     },
772
773     getNodeByPath: function(path) {
774         var toret = this.root;
775         path.forEach(function(idx) {
776             toret = toret.contents()[idx];
777         });
778         return toret;
779     }
780 });
781
782 var defineDocumentProperties = function(doc, $document) {
783     Object.defineProperty(doc, 'root', {get: function() {
784         return doc.createDocumentNode($document[0]);
785     }, configurable: true});
786     Object.defineProperty(doc, 'dom', {get: function() {
787         return $document[0];
788     }, configurable: true});
789 };
790
791 Document.prototype.transformations.register(transformations.createContextTransformation({
792     name: 'smartxml.wrapNodes',
793     // init: function() {
794
795     // },
796     // getChangeRoot: function() {
797     //     return this.context;
798     // },
799     impl: function(args) {
800         this.wrapNodes(args);
801     },
802
803 }));
804
805
806 return {
807     documentFromXML: function(xml) {
808         return new Document(xml);
809     },
810
811     elementNodeFromXML: function(xml) {
812         return this.documentFromXML(xml).root;
813     },
814
815     Document: Document,
816     DocumentNode: DocumentNode,
817     ElementNode: ElementNode
818 };
819
820 });