editor: fixing handling nodeTextChange on text nodes belonging to custom-rendered...
[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: {}}},
12     Dialog = require('views/dialog/dialog'),
13     canvasElements = require('plugins/core/canvasElements');
14
15
16
17 plugin.documentExtension.textNode.transformations = {
18     breakContent: {
19         impl: function(args) {
20             var node = this,
21                 newNodes, emptyText;
22             newNodes = node.split({offset: args.offset});
23             [newNodes.first, newNodes.second].some(function(newNode) {
24                 if(!(newNode.contents().length)) {
25                     emptyText = newNode.append({text: ''});
26                     return true; // break
27                 }
28             });
29             newNodes.second.contents()
30                 .filter(function(child) {
31                     return child.object.describesParent;
32                 })
33                 .forEach(function(child) {
34                     //child.detach();
35                     newNodes.first.append(child);
36                 });
37             return _.extend(newNodes, {emptyText: emptyText});
38         },
39         getChangeRoot: function() {
40             return this.context.parent().parent();
41         }
42     },
43     mergeContentUp: function() {
44         /* globals Node */
45         var myPrev = this,
46             base = this,
47             ret;
48
49         if(myPrev.nodeType === Node.TEXT_NODE) {
50             if(myPrev.getIndex() > 0) {
51                 return;
52             }
53             myPrev = base = myPrev.parent();
54         }
55
56         myPrev = myPrev && myPrev.prev();
57
58         if(myPrev && myPrev.nodeType === Node.ELEMENT_NODE)  {
59             var ptr = this,
60                 next;
61             while(ptr) {
62                 next = ptr.next();
63                 if(!ret) {
64                     ret = myPrev.append(ptr);
65                 } else {
66                     myPrev.append(ptr);
67                 }
68                 
69                 ptr = next;
70             }
71             if(base !== this) {
72                 base.detach();
73             }
74             return {node: ret, offset: ret.sameNode(this) ? null : ret.getText().length - this.getText().length};
75         }
76     }
77 };
78
79 var undoRedoAction = function(dir) {
80     return {
81         name: dir,
82         params: {
83             document: {type: 'context', name: 'document'},
84         },
85         stateDefaults: {
86             label: dir === 'undo' ? '<-' : '->',
87             icon: 'share-alt',
88             iconStyle: dir === 'undo' ? '-webkit-transform: scale(-1,1); transform: scale(-1, 1)' : '',
89             execute: function(callback, params) {
90                 var metadata = _.last(params.document[dir+'Stack']).metadata,
91                     fragment = metadata && metadata.fragment;
92                 params.document[dir]();
93                 if(fragment) {
94                     if(!fragment.isValid()) {
95                         fragment.restoreFromPaths();
96                     }
97                     if(fragment.isValid()) {
98                         callback(fragment);
99                     }
100                 }
101                 callback();
102             },
103         },
104         getState: function(params) {
105             var allowed = params.document && !!(params.document[dir+'Stack'].length),
106                 desc = dir === 'undo' ? gettext('Undo') : gettext('Redo'),
107                 descEmpty = dir === 'undo' ? gettext('There is nothing to undo') : gettext('There is nothing to redo');
108             if(allowed) {
109                 var metadata = _.last(params.document[dir+'Stack']).metadata;
110                 if(metadata) {
111                     desc += ': ' + (metadata.description || gettext('unknown operation'));
112                 }
113             }
114             return {
115                 allowed: allowed,
116                 description: allowed ? desc : descEmpty
117             };
118         }
119     };
120 };
121
122 var pad = function(number) {
123     if(number < 10) {
124         number = '0' + number;
125     }
126     return number;
127 };
128
129 var commentAction = {
130     name: 'comment',
131     params: {
132         fragment: {type: 'context', name: 'fragment'}
133     },
134     stateDefaults: {
135         icon: 'comment',
136         execute: function(callback, params, editor) {
137             /* globals Node */
138             var node = params.fragment.node,
139                 action = this;
140             if(node.nodeType === Node.TEXT_NODE) {
141                 node = node.parent();
142             }
143             node.document.transaction(function() {
144                 var comment =  node.after({tagName: 'aside', attrs: {'class': 'comment'}});
145                 comment.append({text:''});
146                 var user = editor.getUser(), creator;
147                 if(user) {
148                     creator = user.name;
149                     if(user.email) {
150                         creator += ' (' + user.email + ')';
151                     }
152                 } else {
153                     creator = 'anonymous';
154                 }
155
156                 var currentDate = new Date(),
157                     dt = pad(currentDate.getDate()) + '-' +
158                                 pad((currentDate.getMonth() + 1))  + '-' +
159                                 pad(currentDate.getFullYear()) + ' ' +
160                                 pad(currentDate.getHours()) + ':' +
161                                 pad(currentDate.getMinutes()) + ':' +
162                                 pad(currentDate.getSeconds());
163
164                 var metadata = comment.getMetadata();
165                 metadata.add({key: 'creator', value: creator});
166                 metadata.add({key: 'date', value: dt});
167             }, {
168                 metadata: {
169                     description: action.getState().description
170                 },
171                 success: callback
172             });
173         },
174     },
175     getState: function(params) {
176         var state = {
177             allowed: params.fragment && params.fragment.isValid() &&
178                         params.fragment instanceof params.fragment.NodeFragment && !params.fragment.node.isRoot()
179         };
180         if(state.allowed) {
181             state.description = gettext('Insert comment');
182         }
183         return state;
184     }
185 };
186
187
188 var createWrapTextAction = function(createParams) {
189     return {
190         name: createParams.name,
191         params: {
192             fragment: {type: 'context', name: 'fragment'},
193         },
194         getState: function(params) {
195             var state = {
196                     label: this.config.label
197                 },
198                 parent;
199             
200             if(!params.fragment || !params.fragment.isValid()) {
201                 return _.extend(state, {allowed: false});
202             }
203
204             if(params.fragment instanceof params.fragment.CaretFragment && params.fragment.node.isInside(createParams.klass)) {
205                 return _.extend(state, {
206                     allowed: true,
207                     toggled: true,
208                     description: createParams.unwrapDescription,
209                     execute: function(callback, params) {
210                         var node = params.fragment.node,
211                             doc = node.document,
212                             toRemove = node.getParent(createParams.klass),
213                             prefLen = 0;
214
215                         if(node.sameNode(toRemove.contents()[0]) && toRemove.isPrecededByTextNode()) {
216                             prefLen = toRemove.prev().getText().length;
217                         }
218
219                         doc.transaction(function() {
220                             var ret = toRemove.unwrapContent(),
221                                 newFragment = params.fragment;
222                             if(!newFragment.isValid()) {
223                                 newFragment =  doc.createFragment(doc.CaretFragment, {
224                                     node: ret.element1,
225                                     offset: prefLen + params.fragment.offset
226                                 });
227                             }
228                             return newFragment;
229                         }, {
230                             metadata: {
231                                 description: createParams.unwrapDescription,
232                                 fragment: params.fragment
233                             },
234                             success: callback
235                         });
236                     }
237                 });
238             }
239
240             if(params.fragment instanceof params.fragment.TextRangeFragment && params.fragment.hasSiblingBoundries()) {
241                 parent = params.fragment.startNode.parent();
242                 if(parent && parent.is(createParams.klass) || parent.isInside(createParams.klass)) {
243                     return _.extend(state, {allowed: false});
244                 }
245
246                 return _.extend(state, {
247                     allowed: true,
248                     description: createParams.wrapDescription,
249                     execute: function(callback, params) {
250                         params.fragment.document.transaction(function() {
251                             var parent = params.fragment.startNode.parent(),
252                                 doc = params.fragment.document,
253                                 wrapper, lastTextNode;
254                             
255                             wrapper = parent.wrapText({
256                                 _with: {tagName: 'span', attrs: {'class': createParams.klass}},
257                                 offsetStart: params.fragment.startOffset,
258                                 offsetEnd: params.fragment.endOffset,
259                                 textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
260                             });
261                                 
262                             lastTextNode = wrapper.getLastTextNode();
263                             if(lastTextNode) {
264                                 return doc.createFragment(doc.CaretFragment, {node: lastTextNode, offset: lastTextNode.getText().length});
265                             }
266                         }, {
267                             metadata: {
268                                 description: createParams.wrapDescription,
269                                 fragment: params.fragment
270                             },
271                             success: callback
272                         });
273                     }
274                 });
275             }
276             return _.extend(state, {allowed: false});
277         }
278     };
279 };
280
281
282 var createLinkFromSelection = function(callback, params) {
283     var doc = params.fragment.document,
284         dialog = Dialog.create({
285             title: gettext('Create link'),
286             executeButtonText: gettext('Apply'),
287             cancelButtonText: gettext('Cancel'),
288             fields: [
289                 {label: gettext('Link'), name: 'href', type: 'input',
290                 prePasteHandler: function(text) {
291                                     return params.fragment.document.getLinkForUrl(text);
292                                 }.bind(this)
293                 }
294             ]
295         }),
296         action = this;
297     
298     dialog.on('execute', function(event) {
299         doc.transaction(function() {
300             var span =  action.params.fragment.startNode.parent().wrapText({
301                 _with: {tagName: 'span', attrs: {'class': 'link', href: event.formData.href }},
302                 offsetStart: params.fragment.startOffset,
303                 offsetEnd: params.fragment.endOffset,
304                 textNodeIdx: [params.fragment.startNode.getIndex(), params.fragment.endNode.getIndex()]
305             }),
306                 doc = params.fragment.document;
307             event.success();
308             return doc.createFragment(doc.CaretFragment, {node: span.contents()[0], offset:0});
309         }, {
310             metadata: {
311                 description: action.getState().description,
312                 fragment: params.fragment
313             },
314             success: callback
315         });
316     });
317     dialog.show();
318 };
319
320 var editLink = function(callback, params) {
321     var doc = params.fragment.document,
322         link = params.fragment.node.getParent('link'),
323         dialog = Dialog.create({
324             title: gettext('Edit link'),
325             executeButtonText: gettext('Apply'),
326             cancelButtonText: gettext('Cancel'),
327             fields: [
328                 {label: gettext('Link'), name: 'href', type: 'input', initialValue: link.getAttr('href')}
329             ]
330         }),
331         action = this;
332     
333     dialog.on('execute', function(event) {
334         doc.transaction(function() {
335             link.setAttr('href', event.formData.href);
336             event.success();
337             return params.fragment;
338         }, {
339             metadata: {
340                 description: action.getState().description,
341                 fragment: params.fragment
342             },
343             success: callback
344         });
345     });
346     dialog.show();
347 };
348
349 var linkAction = {
350     name: 'link',
351     params: {
352         fragment: {type: 'context', name: 'fragment'}
353     },
354     stateDefaults: {
355         label: gettext('link')
356     },
357     getState: function(params) {
358         if(!params.fragment || !params.fragment.isValid()) {
359             return {allowed: false};
360         }
361
362         if(params.fragment instanceof params.fragment.TextRangeFragment) {
363             if(!params.fragment.hasSiblingBoundries() || params.fragment.startNode.parent().is('link')) {
364                 return {allowed: false};
365             }
366             return {
367                 allowed: true,
368                 description: gettext('Create link from selection'),
369                 execute: createLinkFromSelection
370             };
371         }
372
373         if(params.fragment instanceof params.fragment.CaretFragment) {
374             if(params.fragment.node.isInside('link')) {
375                 return {
376                     allowed: true,
377                     toggled: true,
378                     description: gettext('Edit link'),
379                     execute: editLink
380                 };
381             }
382         }
383         return {allowed: false};
384     }
385 };
386
387
388 plugin.actions = [
389     undoRedoAction('undo'),
390     undoRedoAction('redo'),
391     commentAction,
392     createWrapTextAction({name: 'emphasis', klass: 'emp', wrapDescription: gettext('Mark as emphasized'), unwrapDescription: gettext('Remove emphasis')}),
393     createWrapTextAction({name: 'cite', klass: 'cite', wrapDescription: gettext('Mark as citation'), unwrapDescription: gettext('Remove citation')}),
394     linkAction
395 ].concat(plugin.actions, templates.actions, footnote.actions, switchTo.actions, lists.actions);
396
397
398
399 plugin.config = function(config) {
400     // templates.actions[0].config(config.templates);
401     templates.actions[0].params.template.options = config.templates;
402 };
403
404 plugin.canvasElements = canvasElements;
405
406 return plugin;
407
408 });