wip: blocking nested transformations from being pushed on undo stack
[fnpeditor.git] / src / wlxml / wlxml.js
1 define([
2     'libs/jquery',
3     'libs/underscore',
4     'smartxml/smartxml',
5     'smartxml/transformations'
6 ], function($, _, smartxml, transformations) {
7     
8 'use strict';
9
10 // utils
11
12 var isMetaAttribute = function(attrName) {
13     return attrName.substr(0, 5) === 'meta-';
14 };
15
16 //
17
18 var AttributesList = function() {};
19 AttributesList.prototype = Object.create({});
20 AttributesList.prototype.keys = function() {
21     return _.keys(this);
22 };
23
24 var installObject = function(instance, klass) {
25     var methods = classMethods[klass];
26     if(methods) {
27         instance.object = Object.create(methods);
28         _.keys(methods).forEach(function(key) {
29             instance.object[key] = _.bind(instance.object[key], instance);
30         });
31     }
32 }
33
34 var WLXMLElementNode = function(nativeNode, document) {
35     smartxml.ElementNode.call(this, nativeNode, document);
36     installObject(this, this.getClass());
37 };
38 WLXMLElementNode.prototype = Object.create(smartxml.ElementNode.prototype);
39
40 $.extend(WLXMLElementNode.prototype, smartxml.ElementNode.prototype, {
41     getClass: function() {
42         return this.getAttr('class') || '';
43     },
44     setClass: function(klass) {
45         var methods, object;
46         if(klass !== this.klass) {
47             installObject(this, klass);
48             return this.setAttr('class', klass);
49         }
50     },
51     is: function(klass) {
52         return this.getClass().substr(0, klass.length) === klass;
53     },
54     getMetaAttributes: function() {
55         var toret = new AttributesList(),
56             classParts = [''].concat(this.getClass().split('.')),
57             classCurrent, classDesc;
58
59         classParts.forEach(function(part) {
60             classCurrent = classCurrent ? classCurrent + '.' + part : part;
61             classDesc = this.document.options.wlxmlClasses[classCurrent];
62             if(classDesc) {
63                 _.keys(classDesc.attrs).forEach(function(attrName) {
64                     toret[attrName] = _.extend({value: this.getAttr('meta-' + attrName)}, classDesc.attrs[attrName]);
65                 }.bind(this));
66             }
67         }.bind(this));
68         return toret;
69     },
70     setMetaAttribute: function(key, value) {
71         this.setAttr('meta-'+key, value);
72     },
73     getOtherAttributes: function() {
74         var toret = {};
75         this.getAttrs().forEach(function(attr) {
76             if(attr.name !== 'class' && !isMetaAttribute(attr.name)) {
77                 toret[attr.name] = attr.value;
78             }
79         });
80         return toret;
81     },
82
83     _getXMLDOMToDump: function() {
84         var DOM = this._$.clone(true, true);
85
86         DOM.find('*').addBack().each(function() {
87             var el = $(this),
88                 parent = el.parent(),
89                 contents = parent.contents(),
90                 idx = contents.index(el),
91                 data = el.data();
92
93
94             var txt;
95
96             if(data[formatter_prefix+ 'orig_before']) {
97                 txt = idx > 0 && contents[idx-1].nodeType === Node.TEXT_NODE ? contents[idx-1] : null;
98                 if(txt && txt.data === data[formatter_prefix + 'orig_before_transformed']) {
99                     txt.data = data[formatter_prefix+ 'orig_before_original'];
100                 } else {
101                     el.before(data[formatter_prefix+ 'orig_before']);
102                 }
103             }
104             if(data[formatter_prefix+ 'orig_after']) {
105                 txt = idx < contents.length-1 && contents[idx+1].nodeType === Node.TEXT_NODE ? contents[idx+1] : null;
106                 if(txt && txt.data === data[formatter_prefix + 'orig_after_transformed']) {
107                     txt.data = data[formatter_prefix+ 'orig_after_original'];
108                 } else {
109                     el.after(data[formatter_prefix+ 'orig_after']);
110                 }
111             }
112             if(data[formatter_prefix+ 'orig_begin']) {
113                 el.prepend(data[formatter_prefix+ 'orig_begin']);
114             }
115             if(data[formatter_prefix+ 'orig_end']) {
116                 contents = el.contents();
117                 txt = (contents.length && contents[contents.length-1].nodeType === Node.TEXT_NODE) ? contents[contents.length-1] : null;
118                 if(txt && txt.data === data[formatter_prefix + 'orig_end_transformed']) {
119                     txt.data = data[formatter_prefix+ 'orig_end_original'];
120                 } else {
121                     el.append(data[formatter_prefix+ 'orig_end']);
122                 }
123             }
124         });
125
126         return DOM;
127     }
128 });
129
130 WLXMLElementNode.prototype.transformations.register(transformations.createContextTransformation({
131     name: 'wlxml.setMetaAttribute',
132     impl: function(args) {
133         this.setMetaAttribute(args.name, args.value);
134     },
135     getChangeRoot: function() {
136         return this.context;
137     }
138 }));
139
140
141
142 var WLXMLDocument = function(xml, options) {
143     smartxml.Document.call(this, xml);
144     this.options = options;
145 };
146
147 var formatter_prefix = '_wlxml_formatter_';
148
149 WLXMLDocument.prototype = Object.create(smartxml.Document.prototype);
150 $.extend(WLXMLDocument.prototype, {
151     ElementNodeFactory: WLXMLElementNode,
152
153     loadXML: function(xml) {
154         smartxml.Document.prototype.loadXML.call(this, xml, {silent: true});
155         $(this.dom).find(':not(iframe)').addBack().contents()
156             .filter(function() {return this.nodeType === Node.TEXT_NODE;})
157             .each(function() {
158                 var el = $(this),
159                     text = {original: el.text(), trimmed: $.trim(el.text())},
160                     elParent = el.parent(),
161                     hasSpanParent = elParent.prop('tagName') === 'SPAN',
162                     hasSpanBefore = el.prev().length && $(el.prev()).prop('tagName') === 'SPAN',
163                     hasSpanAfter = el.next().length && $(el.next()).prop('tagName') === 'SPAN';
164
165
166                 var addInfo = function(toAdd, where, transformed, original) {
167                     var parentContents = elParent.contents(),
168                         idx = parentContents.index(el[0]),
169                         prev = idx > 0 ? parentContents[idx-1] : null,
170                         next = idx < parentContents.length - 1 ? parentContents[idx+1] : null,
171                         target, key;
172
173                     if(where === 'above') {
174                         target = prev ? $(prev) : elParent;
175                         key = prev ? 'orig_after' : 'orig_begin';
176                     } else if(where === 'below') {
177                         target = next ? $(next) : elParent;
178                         key = next ? 'orig_before' : 'orig_end';
179                     } else { throw new Error();}
180
181                     target.data(formatter_prefix + key, toAdd);
182                     if(transformed !== undefined) {
183                         target.data(formatter_prefix + key + '_transformed', transformed);
184                     }
185                     if(original !== undefined) {
186                         target.data(formatter_prefix + key + '_original', original);
187                     }
188                 };
189
190                 text.transformed = text.trimmed;
191
192                 if(hasSpanParent || hasSpanBefore || hasSpanAfter) {
193                     var startSpace = /\s/g.test(text.original.substr(0,1)),
194                         endSpace = /\s/g.test(text.original.substr(-1)) && text.original.length > 1;
195                     text.transformed = (startSpace && (hasSpanParent || hasSpanBefore) ? ' ' : '');
196                     text.transformed += text.trimmed;
197                     text.transformed += (endSpace && (hasSpanParent || hasSpanAfter) ? ' ' : '');
198                 } else {
199                     if(text.trimmed.length === 0 && text.original.length > 0 && elParent.contents().length === 1) {
200                         text.transformed = ' ';
201                     }
202                 }
203
204                 if(!text.transformed) {
205                     addInfo(text.original, 'below');
206                     el.remove();
207                     return true; // continue
208                 }
209
210                 if(text.transformed !== text.original) {
211                     // if(!text.trimmed) {
212                     //     addInfo(text.original, 'below');
213                     // } else {
214                         var startingMatch = text.original.match(/^\s+/g),
215                             endingMatch = text.original.match(/\s+$/g),
216                             startingWhiteSpace = startingMatch ? startingMatch[0] : null,
217                             endingWhiteSpace = endingMatch ? endingMatch[0] : null;
218
219                         if(endingWhiteSpace) {
220                             if(text.transformed[text.transformed.length - 1] === ' ' && endingWhiteSpace[0] === ' ') {
221                                 endingWhiteSpace = endingWhiteSpace.substr(1);
222                             }
223                             addInfo(endingWhiteSpace, 'below', !text.trimmed ? text.transformed : undefined, !text.trimmed ? text.original : undefined);
224                         }
225
226                         if(startingWhiteSpace && text.trimmed) {
227                             if(text.transformed[0] === ' ' && startingWhiteSpace[startingWhiteSpace.length-1] === ' ') {
228                                 startingWhiteSpace = startingWhiteSpace.substr(0, startingWhiteSpace.length -1);
229                             }
230                             addInfo(startingWhiteSpace, 'above', !text.trimmed ? text.transformed : undefined, !text.trimmed ? text.original : undefined);
231                         }
232                     //}
233                 }
234
235                 el.replaceWith(document.createTextNode(text.transformed));
236             });
237         this.trigger('contentSet');
238     }
239
240 });
241
242 var wlxmlClasses = {
243     'uri': {
244         attrs: {uri: {type: 'string'}}
245     }
246 };
247
248 var classMethods = {};
249
250 return {
251     WLXMLDocumentFromXML: function(xml, options) {
252         options = _.extend({wlxmlClasses: wlxmlClasses}, options);
253         return new WLXMLDocument(xml, options);
254     },
255
256     WLXMLElementNodeFromXML: function(xml) {
257         return this.WLXMLDocumentFromXML(xml).root;
258     },
259
260     registerExtension: function(extension) {
261         extension.documentTransformations.forEach(function(method) {
262             WLXMLDocument.prototype.transformations.register(transformations.createContextTransformation(method));
263         });
264
265         _.pairs(extension.classMethods).forEach(function(pair) {
266             var className = pair[0],
267                 methods = pair[1];
268             _.pairs(methods).forEach(function(pair) {
269                 var methodName = pair[0],
270                     method = pair[1];
271                 classMethods[className] = classMethods[className] || {};
272                 classMethods[className][methodName] = method;
273             });
274             
275         });
276
277     }
278
279 };
280
281 });