editor: canvas arrow key navigation improvements
[fnpeditor.git] / src / editor / modules / documentCanvas / canvas / keyboard.js
1 define([
2 'libs/jquery',
3 'modules/documentCanvas/canvas/documentElement',
4 'modules/documentCanvas/canvas/utils'
5 ], function($, documentElement, utils) {
6     
7 'use strict';
8 /* globals gettext */
9
10 var KEYS = {
11     ENTER: 13,
12     ARROW_LEFT: 37,
13     ARROW_UP: 38,
14     ARROW_RIGHT: 39,
15     ARROW_DOWN: 40,
16     BACKSPACE: 8,
17     DELETE: 46,
18     X: 88
19 };
20
21 var handleKey = function(event, canvas) {
22     handlers.some(function(handler) {
23         if(handles(handler, event) && handler[event.type]) {
24             handler[event.type](event, canvas);
25             return true;
26         }
27     });
28 };
29
30 var handles = function(handler, event) {
31     if(handler.key === event.which) {
32         return true;
33     }
34     if(handler.keys && handler.keys.indexOf(event.which) !== -1) {
35         return true;
36     }
37     return false;
38 };
39
40
41 var scroll = function(place, textElement) {
42     var rect = textElement.getBoundingClientRect(),
43         scroll = $('#rng-module-documentCanvas-contentWrapper'),
44         border = rect.bottom - (place === 'top' ? rect.height : 0) - scroll.offset().top + scroll[0].scrollTop,
45         visible = scroll[0].scrollTop + {top: 0, bottom: scroll.height()}[place],
46         padding = 16,
47         toScroll = 0;
48     
49     if(place === 'top' && (border - padding < visible)) {
50         toScroll =  border - visible - padding;
51     } else if(place === 'bottom' && (border + padding > visible))  {
52         toScroll = border - visible + padding;
53     }
54     if(toScroll) {
55         scroll[0].scrollTop = scroll[0].scrollTop + toScroll;
56     }
57     return toScroll;
58 };
59
60 var getLastRectAbove = function(node, y) {
61     var rects = node.getClientRects(),
62         idx = 0,
63         rect, toret;
64     while((rect = rects[idx])) {
65         if(rect.bottom < y) {
66             toret = rect;
67         } else {
68             break;
69         }
70         idx++;
71     }
72     return toret;
73 };
74
75 var getFirstRectBelow = function(node, y) {
76     var rects = node.getClientRects(),
77         idx = 0,
78         rect, toret;
79     while((rect = rects[idx])) {
80         if(rect.top > y) {
81             toret = rect;
82             break;
83         }
84         idx++;
85     }
86     return toret;
87 };
88
89 var handlers = [];
90
91
92 handlers.push({key: KEYS.ENTER,
93     keydown: function(event, canvas) {
94         event.preventDefault();
95         var cursor = canvas.getCursor(),
96             position = cursor.getPosition(),
97             element = position.element;
98
99         if(Object.keys(cursor.getPosition()).length === 0) {
100             var currentElement = canvas.getCurrentNodeElement();
101             if(currentElement && !currentElement.wlxmlNode.isRoot()) {
102                 canvas.wlxmlDocument.transaction(function() {
103                     var added = currentElement.wlxmlNode.after({
104                         tagName: currentElement.wlxmlNode.getTagName() || 'div',
105                         attrs: {'class': currentElement.wlxmlNode.getClass() || 'p'}
106                     });
107                     added.append({text:''});
108                     return added;
109                 }, {
110                     metadata: {
111                         description: gettext('Splitting text')
112                     },
113                     success: function(ret) {
114                         canvas.setCurrentElement(utils.getElementForNode(ret), {caretTo: 'start'});
115                     }
116                 });
117
118             }
119             return;
120         }
121
122         if(!cursor.isSelecting()) {
123             if(event.ctrlKey) {
124                 if(element instanceof documentElement.DocumentTextElement) {
125                     element = element.parent();
126                 }
127
128                 canvas.wlxmlDocument.transaction(function() {
129                     var added = element.wlxmlNode.after(
130                         {tagName: element.wlxmlNode.getTagName() || 'div', attrs: {'class': element.wlxmlNode.getClass() || 'p'}}
131                     );
132                     added.append({text: ''});
133                     return added;
134                 }, {
135                     metadata: {
136                         description: gettext('Splitting text')
137                     },
138                     success: function(ret) {
139                         canvas.setCurrentElement(utils.getElementForNode(ret), {caretTo: 'start'});
140                     }
141                 });
142
143             } else {
144
145                 if(!(element.parent().parent())) {
146                     return false; // top level element is unsplittable
147                 }
148
149                 var node = position.element.wlxmlNode,
150                     result, goto, gotoOptions;
151
152                 node.document.transaction(function() {
153                     result = position.element.wlxmlNode.breakContent({offset: position.offset});
154                 }, {
155                     metadata: {
156                         description: gettext('Splitting text')
157                     }
158                 });
159
160                 if(result.emptyText) {
161                     goto = result.emptyText;
162                     gotoOptions = {};
163                 } else {
164                     goto = result.second;
165                     gotoOptions = {caretTo: 'start'};
166                 }
167
168                 canvas.setCurrentElement(utils.getElementForNode(goto), gotoOptions);
169             }
170         }
171     }
172 });
173
174
175 var selectsWholeTextElement = function(cursor) {
176     if(cursor.isSelecting() && cursor.getSelectionStart().offsetAtBeginning && cursor.getSelectionEnd().offsetAtEnd) {
177         return true;
178     }
179     return false;
180 };
181
182 handlers.push({key: KEYS.X,
183     keydown: function(event, canvas) {
184         if(event.ctrlKey && selectsWholeTextElement(canvas.getCursor())) {
185             event.preventDefault();
186         }
187     }
188 });
189
190 handlers.push({keys: [KEYS.BACKSPACE, KEYS.DELETE],
191     keydown: function(event, canvas) {
192         var cursor = canvas.getCursor(),
193             position = canvas.getCursor().getPosition(),
194             element = position.element,
195             node = element ? element.wlxmlNode : null,
196             direction = 'above',
197             caretTo = 'end',
198             goto;
199
200         if(!element || !node) {
201             return;
202         }
203             
204         if(event.which === KEYS.DELETE) {
205             direction = 'below';
206             caretTo = 'start';
207         }
208
209         if(cursor.isSelecting()) {
210             event.preventDefault();
211             var start = cursor.getSelectionStart(),
212                 end = cursor.getSelectionEnd();
213
214             if(direction === 'above') {
215                 if(start.offsetAtBeginning) {
216                     goto = canvas.getNearestTextElement('above', start.element);
217                     caretTo = 'end';
218                 } else {
219                     goto = start.element;
220                     caretTo = start.offset;
221                 }
222             } else {
223                 if(end.offsetAtEnd) {
224                     goto = canvas.getNearestTextElement('below', start.element);
225                     caretTo = 'start';
226                 } else {
227                     goto = end.element;
228                     caretTo = 0;
229                 }
230             }
231
232             canvas.wlxmlDocument.deleteText({
233                 from: {
234                     node: start.element.wlxmlNode,
235                     offset: start.offset
236                 },
237                 to: {
238                     node: end.element.wlxmlNode,
239                     offset: end.offset
240                 }
241             });
242             if(goto) {
243                 canvas.setCurrentElement(goto, {caretTo: caretTo});
244             }
245             return;
246         }
247             
248         var cursorAtOperationEdge = position.offsetAtBeginning;
249         if(event.which === KEYS.DELETE) {
250             cursorAtOperationEdge = position.offsetAtEnd;
251         }
252
253         var willDeleteWholeText = function() {
254             return element.getText().length === 1 || selectsWholeTextElement(cursor);
255         };
256
257         canvas.wlxmlDocument.transaction(function() {
258             if(willDeleteWholeText()) {
259                 event.preventDefault();
260                 node.setText('');
261             }
262             else if(cursorAtOperationEdge) {
263                 if(direction === 'below') {
264                     element = canvas.getNearestTextElement(direction, element);
265                 }
266                 if(element && element.wlxmlNode.getIndex() === 0) {
267                     goto = element.wlxmlNode.parent().moveUp();
268                     if(goto) {
269                         canvas.setCurrentElement(goto.node, {caretTo: goto.offset});
270                     }
271                 }
272                 event.preventDefault();
273             }
274         }, {
275             metadata: {
276                 description: gettext('Remove text')
277             }
278         });
279     }
280 });
281
282 var handleKeyEvent = function(e, s) {
283     keyEventHandlers.some(function(handler) {
284         if(handler.applies(e, s)) {
285             handler.run(e, s);
286             return true;
287         }
288     });
289 };
290 // todo: whileRemoveWholetext
291 var keyEventHandlers = [
292     {
293         applies: function(e, s) {
294             return e.key === KEYS.ARROW_UP && s.type === 'caret';
295         },
296         run: function(e, s) {
297             /* globals window */
298             var caretRect = window.getSelection().getRangeAt(0).getClientRects()[0],
299                 frameRects = s.element.dom[0].getClientRects(),
300                 caretTop = caretRect.bottom - caretRect.height,
301                 position, target,rect, scrolled;
302
303             
304             if((frameRects[0].bottom === caretRect.bottom) || (caretRect.left < frameRects[0].left)) {
305                 e.preventDefault();
306                 s.canvas.rootWrapper.find('[document-text-element]').each(function() {
307                     var test = getLastRectAbove(this, caretTop);
308                     if(test) {
309                         target = this;
310                         rect = test;
311                     } else {
312                         return false;
313                     }
314                 });
315                 if(target) {
316                     scrolled = scroll('top', target);
317                     position = utils.caretPositionFromPoint(caretRect.left, rect.bottom - 1 - scrolled);
318                     s.canvas.setCurrentElement(s.canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
319                 }
320             }
321             if(target) {
322                 scrolled = scroll('top', target);
323                 var left = caretRect.left;
324                 if(left > rect.left + rect.width) {
325                     left = rect.left + rect.width;
326                 } else if(left < rect.left ) {
327                     left = rect.left;
328                 }
329                 position = utils.caretPositionFromPoint(left, rect.bottom - 1 - scrolled);
330                 s.canvas.setCurrentElement(s.canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
331             }
332         }
333     },
334     {
335         applies: function(e, s) {
336             return e.key === KEYS.ARROW_DOWN && s.type === 'caret';
337         },
338         run: function(e, s) {
339             /* globals window */
340             var caretRect = window.getSelection().getRangeAt(0).getClientRects()[0],
341                 frameRects = s.element.dom[0].getClientRects(),
342                 lastRect = frameRects[frameRects.length-1],
343                 position, target,rect, scrolled;
344
345             if(lastRect.bottom === caretRect.bottom || (caretRect.left > lastRect.left + lastRect.width)) {
346                 e.preventDefault();
347                 s.canvas.rootWrapper.find('[document-text-element]').each(function() {
348                     var test = getFirstRectBelow(this, caretRect.bottom);
349                     if(test) {
350                         target = this;
351                         rect = test;
352                         return false;
353                     }
354                 });
355                 if(target) {
356                     scrolled = scroll('bottom', target);
357                     position = utils.caretPositionFromPoint(caretRect.left, rect.top +1 - scrolled);
358                     s.canvas.setCurrentElement(s.canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
359                 }
360             }
361             if(target) {
362                 scrolled = scroll('bottom', target);
363                 var left = caretRect.left;
364                 if(left > rect.left + rect.width) {
365                     left = rect.left + rect.width;
366                 } else if(left < rect.left ) {
367                     left = rect.left;
368                 }
369                 position = utils.caretPositionFromPoint(left, rect.top +1 - scrolled);
370                 s.canvas.setCurrentElement(s.canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
371             }
372         }
373     },
374     {
375         applies: function(e, s) {
376             return e.key === KEYS.ARROW_LEFT && s.type === 'caret';
377         },
378         run: function(e, s) {
379             /* globals window */
380             var prev;
381
382             if(s.offset === 0) {
383                 e.preventDefault();
384                 prev = s.canvas.getPreviousTextElement(s.element);
385                 if(prev) {
386                     scroll('top', prev.dom[0]);
387                     s.canvas.setCurrentElement(s.canvas.getDocumentElement(prev.dom.contents()[0]), {caretTo: 'end'});
388                 }
389             }
390         }
391     },
392     {
393         applies: function(e, s) {
394             return e.key === KEYS.ARROW_RIGHT && s.type === 'caret';
395         },
396         run: function(e, s) {
397             /* globals window */
398             var next;
399             if(s.isAtEnd()) {
400                 e.preventDefault();
401                 next = s.canvas.getNextTextElement(s.element);
402                 if(next) {
403                     scroll('bottom', next.dom[0]);
404                     s.canvas.setCurrentElement(s.canvas.getDocumentElement(next.dom.contents()[0]), {caretTo: 0});
405                 }
406             } else {
407                 var secondToLast = (s.offset === s.element.wlxmlNode.getText().length -1);
408                 if(secondToLast) {
409                     // Only Flying Spaghetti Monster knows why this is need for FF (for versions at least 26 to 31)
410                     e.preventDefault();
411                     s.canvas.setCurrentElement(s.element, {caretTo: 'end'});
412                 }
413             }
414         }
415     },
416     {
417         applies: function(e, s) {
418             return s.type === 'caret' &&
419                 s.element.wlxmlNode.parent().is({tagName: 'span'}) &&
420                 s.element.wlxmlNode.getText().length === 1 &&
421                 s.offset === 1 &&
422                 (e.key === KEYS.BACKSPACE);
423         },
424         run: function(e, s) {
425             var params = {},
426                 prevTextNode = s.element.canvas.getPreviousTextElement(s.element).wlxmlNode;
427             e.preventDefault();
428             s.element.wlxmlNode.parent().detach(params);
429             s.canvas.setCurrentElement(
430                 (params.ret && params.ret.mergedTo) || prevTextNode,
431                 {caretTo: params.ret ? params.ret.previousLen : (prevTextNode ? prevTextNode.getText().length : 0)});
432         }
433     },
434     {
435         applies: function(e, s) {
436             return s.type === 'caret' && (
437                 (s.isAtBeginning() && e.key === KEYS.BACKSPACE) ||
438                 (s.isAtEnd() && e.key === KEYS.DELETE)
439             );
440         },
441         run: function(e,s) {
442             var direction, caretTo, cursorAtOperationEdge, goto, element;
443
444             if(e.key === KEYS.BACKSPACE) {
445                 direction = 'above';
446                 caretTo = 'end';
447                 cursorAtOperationEdge = s.isAtBeginning();
448                 element = s.element;
449             }
450             else {
451                 direction = 'below';
452                 caretTo = 'start';
453                 cursorAtOperationEdge = s.isAtEnd();
454                 element = cursorAtOperationEdge && s.canvas.getNearestTextElement(direction, s.element);
455             }
456
457             if(!cursorAtOperationEdge || !element) {
458                 return;
459             }
460
461             e.preventDefault();
462
463             s.canvas.wlxmlDocument.transaction(function() {
464                 if(element.wlxmlNode.getIndex() === 0) {
465                     goto = element.wlxmlNode.parent().moveUp();
466                 } else {
467                     goto = element.wlxmlNode.moveUp();
468                 }
469                 if(goto) {
470                    s.canvas.setCurrentElement(goto.node, {caretTo: goto.offset});
471                 }
472             }, {
473                 metadata: {
474                     description: gettext('Remove text')
475                 }
476             });
477         }
478     },
479
480     {
481         applies: function(e,s) {
482             return s.type === 'caret' && s.element.getText().length === 1 && (e.key === KEYS.BACKSPACE || e.key === KEYS.DELETE);
483         },
484         run: function(e,s) {
485             e.preventDefault();
486             e.element.wlxmlNode.setText('');
487             s.canvas.setCurrentElement(s.element, {caretTo: 0});
488         }
489     },
490
491     {
492         applies: function(e, s) {
493             return s.type === 'textSelection' && (e.key === KEYS.BACKSPACE || e.key === KEYS.DELETE);
494         },
495         run: function(e, s) {
496             var direction = 'above',
497                 caretTo = 'end',
498                 goto;
499
500             if(e.key === KEYS.DELETE) {
501                 direction = 'below';
502                 caretTo = 'start';
503             }
504
505             e.preventDefault();
506
507             if(s.startsAtBeginning && s.endsAtEnd && s.startElement.sameNode(s.endElement)) {
508                 goto = s.startElement;
509                 caretTo = s.startOffset;
510             } else if(direction === 'above') {
511                 if(s.startsAtBeginning()) {
512                     goto = s.canvas.getNearestTextElement('above', s.startElement);
513                     caretTo = 'end';
514                 } else {
515                     goto = s.startElement;
516                     caretTo = s.startOffset;
517                 }
518             } else {
519                 if(s.endsAtEnd()) {
520                     goto = s.canvas.getNearestTextElement('below', s.startElement);
521                     caretTo = 'start';
522                 } else {
523                     goto = s.endElement;
524                     caretTo = 0;
525                 }
526             }
527
528             var doc = s.canvas.wlxmlDocument;
529             doc.transaction(function() {
530                 
531                 doc.deleteText({
532                     from: {
533                         node: s.startElement.wlxmlNode,
534                         offset: s.startOffset
535                     },
536                     to: {
537                         node: s.endElement.wlxmlNode,
538                         offset: s.endOffset
539                     }
540                 });
541
542             }, {
543                 success: function() {
544                     if(goto) {
545                         s.canvas.setCurrentElement(goto, {caretTo: caretTo});
546                     }
547                 }
548             });
549
550         }
551     },
552     {
553         applies: function(e, s) {
554             return s.type === 'caret' && e.key === KEYS.ENTER && !s.element.parent().isRootElement();
555         },
556         run: function(e, s) {
557             var result, goto, gotoOptions;
558             void(e);
559             e.preventDefault();
560             s.canvas.wlxmlDocument.transaction(function() {
561                 result = s.element.wlxmlNode.breakContent({offset: s.offset});
562             }, {
563                 metadata: {
564                     description: gettext('Splitting text'),
565                     fragment: s.toDocumentFragment()
566                 }
567             });
568
569             if(result.emptyText) {
570                 goto = result.emptyText;
571                 gotoOptions = {};
572             } else {
573                 goto = result.second;
574                 gotoOptions = {caretTo: 'start'};
575             }
576
577             s.canvas.setCurrentElement(utils.getElementForNode(goto), gotoOptions);
578         }
579     }
580 ];
581
582 return {
583     handleKey: handleKey,
584     handleKeyEvent: handleKeyEvent,
585     KEYS: KEYS
586 };
587
588 });