smartxml: removing node attributes by setting undefined as their value
[fnpeditor.git] / src / editor / plugins / core / core.js
1 define(function(require) {
2     
3 'use strict';
4 /* globals gettext */
5
6 var _ = require('libs/underscore'),
7     templates = require('plugins/core/templates'),
8     footnote = require('plugins/core/footnote'),
9     switchTo = require('plugins/core/switch'),
10     lists = require('plugins/core/lists'),
11     plugin = {name: 'core', actions: [], canvas: {}, documentExtension: {textNode: {}, documentNode: {}}},
12     Dialog = require('views/dialog/dialog'),
13     canvasElements = require('plugins/core/canvasElements'),
14     metadataEditor = require('plugins/core/metadataEditor/metadataEditor'),
15     edumed = require('plugins/core/edumed/edumed');
16
17
18 var exerciseFix = function(newNodes) {
19     var list, exercise, max, addedItem, answerValues;
20     if(newNodes.created.is('item')) {
21         list = newNodes.created.parent();
22         exercise = list.parent();
23         if(exercise && exercise.is('exercise')) {
24             if(exercise.is('exercise.order')) {
25                 answerValues = exercise.object.getItems()
26                             .map(function(item) {
27                                 if(!addedItem && item.node.sameNode(newNodes.created)) {
28                                     addedItem = item;
29                                 }
30                                 return item.getAnswer();
31                             });
32                 max = Math.max.apply(Math.max, answerValues);
33                 addedItem.setAnswer(max + 1);
34             }
35         }
36     }
37 };
38
39 plugin.documentExtension.textNode.transformations = {
40     breakContent: {
41         impl: function(args) {
42             var node = this,
43                 isSpan = node.parent().getTagName() === 'span',
44                 parentDescribingNodes = [],
45                 newNodes, emptyText;
46             newNodes = node.split({offset: args.offset});
47             newNodes.second.contents()
48                 .filter(function(child) {
49                     return child.object.describesParent;
50                 })
51                 .forEach(function(child) {
52                     //child.detach();
53                     parentDescribingNodes.push(child);
54                     child.detach();
55                 });
56             [newNodes.first, newNodes.second].some(function(newNode) {
57                 if(!(newNode.contents().length)) {
58                     emptyText = newNode.append({text: ''});
59                     return true; // break
60                 }
61             });
62
63             /* <hack>
64             /*
65                 This makes sure that adding a new item to the list in some of the edumed exercises
66                 sets an answer attribute that makes sense (and not just copies it which would create
67                 a duplicate value).
68
69                 This won't be neccessary when/if we introduce canvas element own key event handlers.
70
71                 Alternatively, WLXML elements could implement their own item split methods that we
72                 would delegate to.
73             */
74                 exerciseFix(newNodes);
75             /* </hack> */
76
77             parentDescribingNodes.forEach(function(node) {
78                 newNodes.first.append(node);
79             });
80
81             var parent, newNode;
82
83             var copyNode = function(n) {
84                 var attrs = {};
85                 n.getAttrs().forEach(function(attr) {
86                     attrs[attr.name] = attr.value;
87                 });
88
89                 return node.document.createDocumentNode({
90                     tagName: n.getTagName(),
91                     attrs: attrs
92                 });
93             };
94
95             var move = function(node, to) {
96                 var copy;
97                 if(!node.containsNode(newNodes.second)) {
98                     to.append(node);
99                     return false;
100                 } else {
101                     if(!node.sameNode(newNodes.second)) {
102                         copy = to.append(copyNode(node));
103                         node.contents().some(function(n) {
104                             return move(n, copy);
105                         });
106                     }
107                     return true;
108                 }
109             };
110
111             if(isSpan) {
112                 newNodes.first.parents().some(function(p) {
113                     if(p.getTagName() !== 'span') {
114                         parent = p;
115                         return true;
116                     }
117                 });
118                 newNode = parent.before({tagName: parent.getTagName(), attrs: {'class': parent.getClass()}});
119                 parent.contents().some(function(n) {
120                     return move(n, newNode);
121                 });
122             }
123
124             return _.extend(newNodes, {emptyText: emptyText});
125         }
126     },
127     mergeContentUp: function() {
128         /* globals Node */
129         var myPrev = this,
130             base = this,
131             ret;
132
133         if(myPrev.nodeType === Node.TEXT_NODE) {
134             if(myPrev.getIndex() > 0) {
135                 return;
136             }
137             myPrev = base = myPrev.parent();
138         }
139
140         myPrev = myPrev && myPrev.prev();
141
142         if(myPrev && myPrev.nodeType === Node.ELEMENT_NODE)  {
143             var ptr = this,
144                 next;
145             while(ptr) {
146                 next = ptr.next();
147                 if(!ret) {
148                     ret = myPrev.append(ptr);
149                 } else {
150                     myPrev.append(ptr);
151                 }
152                 
153                 ptr = next;
154             }
155             if(base !== this) {
156                 base.detach();
157             }
158             return {node: ret, offset: ret.sameNode(this) ? null : ret.getText().length - this.getText().length};
159         }
160     }
161 };
162
163 plugin.documentExtension.documentNode.transformations = {
164     moveUp: function() {
165         var toMerge = this,
166             prev = toMerge.prev();
167
168         var merge = function(from, to) {
169             var toret;
170             from.contents().forEach(function(node, idx) {
171                 var len, ret;
172                 if(idx === 0 && node.nodeType === Node.TEXT_NODE) {
173                     len = node.getText().length;
174                 }
175                 ret = to.append(node);
176                 
177                 if(idx === 0 && ret.nodeType === Node.TEXT_NODE) {
178                     toret = {
179                         node: ret,
180                         offset: ret.getText().length - len
181                     };
182                 } else if(!toret) {
183                     toret = {
184                         node: ret.getFirstTextNode(),
185                         offset: 0
186                     };
187                 }
188             });
189             from.detach();
190             return toret;
191         };
192
193         var strategies = [
194             {
195                 applies: function() {
196                     return toMerge.nodeType === Node.TEXT_NODE && prev.is({tagName: 'span'});
197                 },
198                 run: function() {
199                     var textNode = prev.getLastTextNode(),
200                         txt, prevText, prevTextLen;
201                     if(textNode) {
202                         txt = textNode.getText();
203                         if(txt.length > 1) {
204                             textNode.setText(txt.substr(0, txt.length-1));
205                             return {node: toMerge, offset: 0};
206                         } else {
207                             if((prevText = prev.prev()) && prevText.nodeType === Node.TEXT_NODE) {
208                                 prevTextLen = prevText.getText().length;
209                             }
210                             prev.detach();
211                             return {
212                                 node: prevText ? prevText : toMerge,
213                                 offset : prevText ? prevTextLen : 0
214                             };
215                         }
216                     }
217                 }
218             },
219             {
220                 applies: function() {
221                     return toMerge.is({tagName: 'div', 'klass': 'p'}) || (toMerge.is({tagName: 'div'}) && toMerge.getClass() === '');
222                 },
223                 run: function() {
224                     if(prev && (prev.is('p') || prev.is({tagName: 'header'}))) {
225                         return merge(toMerge, prev);
226                     }
227                     if(prev && prev.is('list')) {
228                         var items = prev.contents().filter(function(n) { return n.is('item');});
229                         return merge(toMerge, items[items.length-1]);
230                     }
231                 }
232             },
233             {
234                 applies: function() {
235                     return toMerge.is({tagName: 'span'});
236                 },
237                 run: function() {
238                     /* globals Node */
239                     var toret = {node: toMerge.contents()[0] , offset: 0},
240                         txt, txtNode, parent;
241                     if(!prev) {
242                         toMerge.parents().some(function(p) {
243                             if(p.is({tagName: 'span'})) {
244                                 parent = prev = p;
245                             } else {
246                                 if(!parent) {
247                                     parent = p;
248                                 }
249                                 prev = prev && prev.prev();
250                                 return true;
251                             }
252                         });
253                     }
254                     if(!prev) {
255                         return parent.moveUp();
256                     }
257                     else if(prev.nodeType === Node.TEXT_NODE && (txt = prev.getText())) {
258                         prev.setText(txt.substr(0, txt.length-1));
259                         return toret;
260                     } else if(prev.is({tagName: 'span'})) {
261                         if((txtNode = prev.getLastTextNode())) {
262                             txt = txtNode.getText();
263                             if(txt.length > 1) {
264                                 txtNode.setText(txt.substr(0, txt.length-1));
265                             } else {
266                                 if(txtNode.parent().contents().length === 1) {
267                                     txtNode.parent().detach();
268                                 } else {
269                                     txtNode.detach();
270                                 }
271
272                             }
273                             return toret;
274                         }
275                     }
276
277                 }
278             },
279             {
280                 applies: function() {
281                     return toMerge.is({tagName: 'header'});
282                 },
283                 run: function() {
284                     if(prev && prev.is('p') || prev.is({tagName: 'header'})) {
285                         return merge(toMerge, prev);
286                     }
287                 }
288             },
289             {
290                 applies: function() {
291                     return toMerge.is('item');
292                 },
293                 run: function() {
294                     var list;
295                     if(prev && prev.is('item')) {
296                         return merge(toMerge, prev);
297                     } else if(!prev && (list = toMerge.parent()) && list.is('list')) {
298                         list.before(toMerge);
299                         toMerge.setClass('p');
300                         if(!list.contents().length) {
301                             list.detach();
302                         }
303                         return {node: toMerge.contents()[0], offset:0};
304                     }
305                 }
306             }
307         ];
308
309         var toret;
310         strategies.some(function(strategy) {
311             if(strategy.applies()) {
312                 toret = strategy.run();
313                 return true;
314             }
315         });
316         return toret;
317     }
318 };
319
320 var undoRedoAction = function(dir) {
321     return {
322         name: dir,
323         params: {
324             document: {type: 'context', name: 'document'},
325         },
326         stateDefaults: {
327             label: dir === 'undo' ? '<-' : '->',
328             icon: 'share-alt',
329             iconStyle: dir === 'undo' ? '-webkit-transform: scale(-1,1); transform: scale(-1, 1)' : '',
330             execute: function(callback, params) {
331                 var metadata = _.last(params.document[dir+'Stack']).metadata,
332                     fragment = metadata && metadata.fragment;
333                 params.document[dir]();
334                 if(fragment) {
335                     if(!fragment.isValid()) {
336                         fragment.restoreFromPaths();
337                     }
338                     if(fragment.isValid()) {
339                         callback(fragment);
340                     }
341                 }
342                 callback();
343             },
344         },
345         getState: function(params) {
346             var allowed = params.document && !!(params.document[dir+'Stack'].length),
347                 desc = dir === 'undo' ? gettext('Undo') : gettext('Redo'),
348                 descEmpty = dir === 'undo' ? gettext('There is nothing to undo') : gettext('There is nothing to redo');
349             if(allowed) {
350                 var metadata = _.last(params.document[dir+'Stack']).metadata;
351                 if(metadata) {
352                     desc += ': ' + (metadata.description || gettext('unknown operation'));
353                 }
354             }
355             return {
356                 allowed: allowed,
357                 description: allowed ? desc : descEmpty
358             };
359         }
360     };
361 };
362
363 var pad = function(number) {
364     if(number < 10) {
365         number = '0' + number;
366     }
367     return number;
368 };
369
370 var commentAction = {
371     name: 'comment',
372     params: {
373         fragment: {type: 'context', name: 'fragment'}
374     },
375     stateDefaults: {
376         icon: 'comment',
377         execute: function(callback, params, editor) {
378             /* globals Node */
379             var node = params.fragment.node,
380                 action = this;
381             if(node.nodeType === Node.TEXT_NODE) {
382                 node = node.parent();
383             }
384             node.document.transaction(function() {
385                 var comment =  node.after({tagName: 'aside', attrs: {'class': 'comment'}});
386                 comment.append({text:''});
387                 var user = editor.getUser(), creator;
388                 if(user) {
389                     creator = user.name;
390                     if(user.email) {
391                         creator += ' (' + user.email + ')';
392                     }
393                 } else {
394                     creator = 'anonymous';
395                 }
396
397                 var currentDate = new Date(),
398                     dt = pad(currentDate.getDate()) + '-' +
399                                 pad((currentDate.getMonth() + 1))  + '-' +
400                                 pad(currentDate.getFullYear()) + ' ' +
401                                 pad(currentDate.getHours()) + ':' +
402                                 pad(currentDate.getMinutes()) + ':' +
403                                 pad(currentDate.getSeconds());
404
405                 var metadata = comment.getMetadata();
406                 metadata.add({key: 'creator', value: creator});
407                 metadata.add({key: 'date', value: dt});
408             }, {
409                 metadata: {
410                     description: action.getState().description
411                 },
412                 success: callback
413             });
414         },
415     },
416     getState: function(params) {
417         var state = {
418             allowed: params.fragment && params.fragment.isValid() &&
419                         params.fragment instanceof params.fragment.NodeFragment && !params.fragment.node.isRoot()
420         };
421         if(state.allowed) {
422             state.description = gettext('Insert comment');
423         }
424         return state;
425     }
426 };
427
428
429 var createWrapTextAction = function(createParams) {
430     return {
431         name: createParams.name,
432         params: {
433             fragment: {type: 'context', name: 'fragment'},
434         },
435         getState: function(params) {
436             var state = {
437                     label: this.config.label
438                 },
439                 parent;
440             
441             if(!params.fragment || !params.fragment.isValid()) {
442                 return _.extend(state, {allowed: false});
443             }
444
445             if(params.fragment instanceof params.fragment.CaretFragment && params.fragment.node.isInside(createParams.klass)) {
446                 return _.extend(state, {
447                     allowed: true,
448                     toggled: true,
449                     description: createParams.unwrapDescription,
450                     execute: function(callback, params) {
451                         var node = params.fragment.node,
452                             doc = node.document,
453                             toRemove = node.getParent(createParams.klass),
454                             prefLen = 0;
455
456                         if(node.sameNode(toRemove.contents()[0]) && toRemove.isPrecededByTextNode()) {
457                             prefLen = toRemove.prev().getText().length;
458                         }
459
460                         doc.transaction(function() {
461                             var ret = toRemove.unwrapContent(),
462                                 newFragment = params.fragment;
463                             if(!newFragment.isValid()) {
464                                 newFragment =  doc.createFragment(doc.CaretFragment, {
465                                     node: ret.element1,
466                                     offset: prefLen + params.fragment.offset
467                                 });
468                             }
469                             return newFragment;
470                         }, {
471                             metadata: {
472                                 description: createParams.unwrapDescription,
473                                 fragment: params.fragment
474                             },
475                             success: callback
476                         });
477                     }
478                 });
479             }
480
481             if(params.fragment instanceof params.fragment.TextRangeFragment && params.fragment.hasSiblingBoundries()) {
482                 parent = params.fragment.startNode.parent();
483                 if(parent && parent.is(createParams.klass) || parent.isInside(createParams.klass)) {
484                     return _.extend(state, {allowed: false});
485                 }
486
487                 return _.extend(state, {
488                     allowed: true,
489                     description: createParams.wrapDescription,
490                     execute: function(callback, params) {
491                         params.fragment.document.transaction(function() {
492                             var parent = params.fragment.startNode.parent(),
493                                 doc = params.fragment.document,
494                                 wrapper, lastTextNode;
495                             
496                             wrapper = parent.wrapText({
497                                 _with: {tagName: 'span', attrs: {'class': createParams.klass}},
498                                 offsetStart: params.fragment.startOffset,
499                                 offsetEnd: params.fragment.endOffset,
500                                 textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
501                             });
502                                 
503                             lastTextNode = wrapper.getLastTextNode();
504                             if(lastTextNode) {
505                                 return doc.createFragment(doc.CaretFragment, {node: lastTextNode, offset: lastTextNode.getText().length});
506                             }
507                         }, {
508                             metadata: {
509                                 description: createParams.wrapDescription,
510                                 fragment: params.fragment
511                             },
512                             success: callback
513                         });
514                     }
515                 });
516             }
517             return _.extend(state, {allowed: false});
518         }
519     };
520 };
521
522
523 var createLinkFromSelection = function(callback, params) {
524     var doc = params.fragment.document,
525         dialog = Dialog.create({
526             title: gettext('Create link'),
527             executeButtonText: gettext('Apply'),
528             cancelButtonText: gettext('Cancel'),
529             fields: [
530                 {label: gettext('Link'), name: 'href', type: 'input',
531                 prePasteHandler: function(text) {
532                                     return params.fragment.document.getLinkForUrl(text);
533                                 }.bind(this)
534                 }
535             ]
536         }),
537         action = this;
538     
539     dialog.on('execute', function(event) {
540         doc.transaction(function() {
541             var span =  action.params.fragment.startNode.parent().wrapText({
542                 _with: {tagName: 'span', attrs: {'class': 'link', href: event.formData.href }},
543                 offsetStart: params.fragment.startOffset,
544                 offsetEnd: params.fragment.endOffset,
545                 textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
546             }),
547                 doc = params.fragment.document;
548             event.success();
549             return doc.createFragment(doc.CaretFragment, {node: span.contents()[0], offset:0});
550         }, {
551             metadata: {
552                 description: action.getState().description,
553                 fragment: params.fragment
554             },
555             success: callback
556         });
557     });
558     dialog.show();
559 };
560
561 var editLink = function(callback, params) {
562     var doc = params.fragment.document,
563         link = params.fragment.node.getParent('link'),
564         dialog = Dialog.create({
565             title: gettext('Edit link'),
566             executeButtonText: gettext('Apply'),
567             cancelButtonText: gettext('Cancel'),
568             fields: [
569                 {label: gettext('Link'), name: 'href', type: 'input', initialValue: link.getAttr('href')}
570             ]
571         }),
572         action = this;
573     
574     dialog.on('execute', function(event) {
575         doc.transaction(function() {
576             link.setAttr('href', event.formData.href);
577             event.success();
578             return params.fragment;
579         }, {
580             metadata: {
581                 description: action.getState().description,
582                 fragment: params.fragment
583             },
584             success: callback
585         });
586     });
587     dialog.show();
588 };
589
590 var linkAction = {
591     name: 'link',
592     params: {
593         fragment: {type: 'context', name: 'fragment'}
594     },
595     stateDefaults: {
596         label: gettext('link')
597     },
598     getState: function(params) {
599         if(!params.fragment || !params.fragment.isValid()) {
600             return {allowed: false};
601         }
602
603         if(params.fragment instanceof params.fragment.TextRangeFragment) {
604             if(!params.fragment.hasSiblingBoundries() || params.fragment.startNode.parent().is('link')) {
605                 return {allowed: false};
606             }
607             return {
608                 allowed: true,
609                 description: gettext('Create link from selection'),
610                 execute: createLinkFromSelection
611             };
612         }
613
614         if(params.fragment instanceof params.fragment.CaretFragment) {
615             if(params.fragment.node.isInside('link')) {
616                 return {
617                     allowed: true,
618                     toggled: true,
619                     description: gettext('Edit link'),
620                     execute: editLink
621                 };
622             }
623         }
624         return {allowed: false};
625     }
626 };
627
628 var metadataParams = {};
629
630 plugin.actions = [
631     undoRedoAction('undo'),
632     undoRedoAction('redo'),
633     commentAction,
634     createWrapTextAction({name: 'emphasis', klass: 'emp', wrapDescription: gettext('Mark as emphasized'), unwrapDescription: gettext('Remove emphasis')}),
635     createWrapTextAction({name: 'cite', klass: 'cite', wrapDescription: gettext('Mark as citation'), unwrapDescription: gettext('Remove citation')}),
636     linkAction,
637     metadataEditor.action(metadataParams)
638 ].concat(plugin.actions, templates.actions, footnote.actions, switchTo.actions, lists.actions, edumed.actions);
639
640
641 plugin.config = function(config) {
642     // templates.actions[0].config(config.templates);
643     templates.actions[0].params.template.options = config.templates;
644     metadataParams.config = (config.metadata || []).sort(function(configRow1, configRow2) {
645         if(configRow1.key < configRow2.key) {
646             return -1;
647         }
648         if(configRow1.key > configRow2.key) {
649             return 1;
650         }
651         return 0;
652     });
653 };
654
655 plugin.canvasElements = canvasElements.concat(edumed.canvasElements);
656
657 return plugin;
658
659 });