Handling empty inline elements
[fnpeditor.git] / modules / documentCanvas / canvas / canvas.js
1 define([
2 'libs/jquery-1.9.1.min',
3 'libs/underscore-min',
4 'modules/documentCanvas/canvas/documentElement',
5 'modules/documentCanvas/canvas/utils'
6 ], function($, _, documentElement, utils) {
7     
8 'use strict';
9
10 var Canvas = function(wlxml, publisher) {
11     this.loadWlxml(wlxml);
12     this.publisher = publisher ? publisher : function() {};
13 };
14
15 $.extend(Canvas.prototype, {
16
17     loadWlxml: function(wlxml) {
18         var d = wlxml ? $($.trim(wlxml)) : null;
19         if(d) {
20             this.wrapper = $('<div>').addClass('canvas-wrapper').attr('contenteditable', true);
21             this.wrapper.append(d);
22             
23             this.wrapper.find('*').replaceWith(function() {
24                 var currentTag = $(this);
25                 if(currentTag.attr('wlxml-tag'))
26                     return;
27
28                 var meta = {}, others = {};
29                 for(var i = 0; i < this.attributes.length; i++) {
30                     var attr = this.attributes[i];
31                     if(attr.name.substr(0, 5) === 'meta-')
32                         meta[attr.name.substr(5)] = attr.value;
33                     else if(attr.name !== 'class')
34                         others[attr.name] = attr.value;
35                 }
36
37                 var element = documentElement.DocumentNodeElement.create({
38                     tag: currentTag.prop('tagName').toLowerCase(),
39                     klass: currentTag.attr('class'),
40                     meta: meta,
41                     others: others
42                 });
43
44                 element.dom().append(currentTag.contents());
45                 ['orig-before', 'orig-after', 'orig-begin', 'orig-end'].forEach(function(attr) {
46                     element.data(attr, '');
47                 });
48                 return element.dom();
49             });
50
51             this.wrapper.find(':not(iframe)').addBack().contents()
52                 .filter(function() {return this.nodeType === Node.TEXT_NODE})
53                 .each(function() {
54                     // TODO: use DocumentElement API
55
56                     var el = $(this),
57                         text = {original: el.text(), trimmed: $.trim(el.text())},
58                         elParent = el.parent(),
59                         hasSpanParent = elParent.attr('wlxml-tag') === 'span',
60                         hasSpanBefore = el.prev().length > 0  && $(el.prev()[0]).attr('wlxml-tag') === 'span',
61                         hasSpanAfter = el.next().length > 0 && $(el.next()[0]).attr('wlxml-tag') === 'span';
62
63
64                     var addInfo = function(toAdd, where) {
65                         var parentContents = elParent.contents(),
66                             idx = parentContents.index(el[0]),
67                             prev = idx > 0 ? parentContents[idx-1] : null,
68                             next = idx < parentContents.length - 1 ? parentContents[idx+1] : null,
69                             target, key;
70
71                         if(where === 'above') {
72                             target = prev ? $(prev) : elParent;
73                             key = prev ? 'orig-after' : 'orig-begin';
74                         } else if(where === 'below') {
75                             target = next ? $(next) : elParent;
76                             key = next ? 'orig-before' : 'orig-end';
77                         } else { throw new Object;}
78
79                         target.data(key, toAdd);
80                     }
81
82                     text.transformed = text.trimmed;
83                     
84                     if(hasSpanParent || hasSpanBefore || hasSpanAfter) {
85                         var startSpace = /\s/g.test(text.original.substr(0,1)),
86                             endSpace = /\s/g.test(text.original.substr(-1)) && text.original.length > 1;
87                         text.transformed = (startSpace && (hasSpanParent || hasSpanBefore) ? ' ' : '')
88                                     + text.trimmed
89                                     + (endSpace && (hasSpanParent || hasSpanAfter) ? ' ' : '');
90                     } else {
91                         if(text.trimmed.length === 0 && text.original.length > 0 && elParent.contents().length === 1)
92                             text.transformed = ' ';
93                     }
94
95                     if(!text.transformed) {
96                         addInfo(text.original, 'below');
97                         el.remove();
98                         return true; // continue
99                     }
100
101                     if(text.transformed !== text.original) {
102                         if(!text.trimmed) {
103                             addInfo(text.original, 'below');
104                         } else {
105                             var startingMatch = text.original.match(/^\s+/g),
106                                 endingMatch = text.original.match(/\s+$/g),
107                                 startingWhiteSpace = startingMatch ? startingMatch[0] : null,
108                                 endingWhiteSpace = endingMatch ? endingMatch[0] : null;
109
110                             if(endingWhiteSpace) {
111                                 if(text.transformed[text.transformed.length - 1] === ' ' && endingWhiteSpace[0] === ' ')
112                                     endingWhiteSpace = endingWhiteSpace.substr(1);
113                                 addInfo(endingWhiteSpace, 'below');
114                             }
115
116                             if(startingWhiteSpace) {
117                                 if(text.transformed[0] === ' ' && startingWhiteSpace[startingWhiteSpace.length-1] === ' ')
118                                     startingWhiteSpace = startingWhiteSpace.substr(0, startingWhiteSpace.length -1);
119                                 addInfo(startingWhiteSpace, 'above');
120                             }
121                         }
122                     }
123
124                     var element = documentElement.DocumentTextElement.create({text: text.transformed});
125                     el.replaceWith(element.dom());
126                 });
127             
128             this.d = this.wrapper.children(0);
129
130             var canvas = this;
131             this.wrapper.on('keydown', function(e) {
132                 if(e.which === 13) { 
133                     e.preventDefault();
134                     var cursor = canvas.getCursor();
135                     if(!cursor.isSelecting()) {
136                         var position = cursor.getPosition(),
137                             elements = position.element.split({offset: position.offset}),
138                             newEmpty,
139                             goto;
140
141                         if(position.offsetAtBeginning)
142                             newEmpty = elements.first;
143                         else if(position.offsetAtEnd)
144                             newEmpty = elements.second;
145                         if(newEmpty) {
146                             goto = newEmpty.append(documentElement.DocumentTextElement.create({text: ''}, this));
147                             canvas.setCurrentElement(goto);
148                         }
149                     }
150                 }
151             });
152
153
154             var KEYS = {
155                 ARROW_LEFT: 37,
156                 ARROW_UP: 38,
157                 ARROW_RIGHT: 39,
158                 ARROW_DOWN: 40
159             }
160
161             this.wrapper.on('keyup', function(e) {
162                 if(e.which >= 37 && e.which <= 40)
163                     canvas.setCurrentElement(canvas.getCursor().getPosition().element, {caretTo: false})
164             });
165          
166             this.wrapper.on('keydown', function(e) {
167                 if(e.which >= 37 && e.which <= 40) {
168                     var position = canvas.getCursor().getPosition(),
169                         element = position.element;
170                     if(element && (element instanceof documentElement.DocumentTextElement)) {
171                         if(element.isEmpty()) {
172                             var direction, caretTo;
173                             if(e.which === KEYS.ARROW_LEFT  || e.which === KEYS.ARROW_UP) {
174                                 direction = 'above';
175                                 caretTo = 'end';
176                             } else {
177                                 direction = 'below';
178                                 caretTo = 'start';
179                             }
180                             var el = canvas.getDocumentElement(utils.nearestInDocumentOrder('[document-text-element]', direction, window.getSelection().focusNode))
181                             canvas.setCurrentElement(element, {caretTo: caretTo});
182                         } else {
183                             var txt = element.dom().contents()[0].data;
184                             if(e.which === KEYS.ARROW_LEFT && position.offset > 1 && txt.charAt(position.offset-2) === utils.unicode.ZWS) {
185                                 e.preventDefault();
186                                 canvas._moveCaretToTextElement(element, position.offset-2);
187                             }
188                             if(e.which === KEYS.ARROW_RIGHT && position.offset < txt.length - 1 && txt.charAt(position.offset+1) === utils.unicode.ZWS) {
189                                 e.preventDefault();
190                                 canvas._moveCaretToTextElement(element, position.offset+2);
191                             }
192                         }
193                     }
194
195
196                 }
197             });
198
199             this.wrapper.on('click', '[wlxml-tag], [wlxml-text]', function(e) {
200                 e.stopPropagation();
201                 canvas.setCurrentElement(canvas.getDocumentElement(e.target), {caretTo: false});
202             });
203
204
205             var observer = new MutationObserver(function(mutations) {
206                 mutations.forEach(function(mutation) {
207                     if(documentElement.DocumentTextElement.isContentContainer(mutation.target) && mutation.target.data === '')
208                         mutation.target.data = utils.unicode.ZWS;
209                 });
210             });
211             var config = { attributes: false, childList: false, characterData: true, subtree: true, characterDataOldValue: true};
212             observer.observe(this.d[0], config);
213
214         } else {
215             this.d = null;
216         }
217     },
218
219     view: function() {
220         return this.wrapper;
221     },
222
223     doc: function() {
224         if(this.d === null)
225             return null;
226         return documentElement.DocumentNodeElement.fromHTMLElement(this.d.get(0), this); //{wlxmlTag: this.d.prop('tagName')};
227     },
228
229     wrapText: function(params) {
230         params = _.extend({textNodeIdx: 0}, params);
231         if(typeof params.textNodeIdx === 'number')
232             params.textNodeIdx = [params.textNodeIdx];
233         
234         var childrenInside = params.inside.children(),
235             idx1 = Math.min.apply(Math, params.textNodeIdx),
236             idx2 = Math.max.apply(Math, params.textNodeIdx),
237             textNode1 = childrenInside[idx1],
238             textNode2 = childrenInside[idx2],
239             sameNode = textNode1.sameNode(textNode2),
240             prefixOutside = textNode1.getText().substr(0, params.offsetStart),
241             prefixInside = textNode1.getText().substr(params.offsetStart),
242             suffixInside = textNode2.getText().substr(0, params.offsetEnd),
243             suffixOutside = textNode2.getText().substr(params.offsetEnd)
244         ;
245         
246         var wrapperElement = documentElement.DocumentNodeElement.create({tag: params._with.tag, klass: params._with.klass});
247         textNode1.after(wrapperElement);
248         textNode1.detach();
249         
250         if(prefixOutside.length > 0)
251             wrapperElement.before({text:prefixOutside});
252         if(sameNode) {
253             var core = textNode1.getText().substr(params.offsetStart, params.offsetEnd - params.offsetStart);
254             wrapperElement.append({text: core});
255         } else {
256             textNode2.detach();
257             if(prefixInside.length > 0)
258                 wrapperElement.append({text: prefixInside});
259             for(var i = idx1 + 1; i < idx2; i++) {
260                 wrapperElement.append(childrenInside[i]);
261             }
262             if(suffixInside.length > 0)
263                 wrapperElement.append({text: suffixInside});
264         }
265         if(suffixOutside.length > 0)
266             wrapperElement.after({text: suffixOutside});
267         return wrapperElement;
268     },
269     getDocumentElement: function(from) {
270         if(from instanceof HTMLElement || from instanceof Text) {
271            return documentElement.DocumentElement.fromHTMLElement(from, this);
272         }
273     },
274     getCursor: function() {
275         return new Cursor(this);
276     },
277
278     list: {},
279
280
281
282     highlightElement: function(element) {
283         this.wrapper.find('.highlighted-element').removeClass('highlighted-element');
284         element.dom().addClass('highlighted-element');
285     },
286
287     dimElement: function(element) {
288         element.dom().removeClass('highlighted-element');
289     },
290     
291     getCurrentNodeElement: function() {
292         return this.getDocumentElement(this.wrapper.find('.current-node-element')[0]);
293     },
294
295     getCurrentTextElement: function() {
296         return this.getDocumentElement(this.wrapper.find('.current-text-element')[0]);
297     },
298
299
300
301     setCurrentElement: function(element, params) {
302         params = _.extend({caretTo: 'end'}, params);
303         var findFirstDirectTextChild = function(e, nodeToLand) {
304             var byBrowser = this.getCursor().getPosition().element;
305             if(byBrowser && byBrowser.parent().sameNode(nodeToLand))
306                 return byBrowser;
307             var children = e.children();
308             for(var i = 0; i < children.length; i++) {
309                 if(children[i] instanceof documentElement.DocumentTextElement)
310                     return children[i];
311             }
312             return null;
313         }.bind(this);
314         var _markAsCurrent = function(element) {
315             if(element instanceof documentElement.DocumentTextElement) {
316                 this.wrapper.find('.current-text-element').removeClass('current-text-element');
317                 element.dom().addClass('current-text-element');
318             } else {
319                 this.wrapper.find('.current-node-element').removeClass('current-node-element')
320                 element.dom().addClass('current-node-element');
321                 this.publisher('currentElementChanged', element);
322             }
323         }.bind(this);
324
325
326         var isTextElement = element instanceof documentElement.DocumentTextElement,
327             nodeElementToLand = isTextElement ? element.parent() : element,
328             textElementToLand = isTextElement ? element : findFirstDirectTextChild(element, nodeElementToLand),
329             currentTextElement = this.getCurrentTextElement(),
330             currentNodeElement = this.getCurrentNodeElement();
331
332         if(currentTextElement && !(currentTextElement.sameNode(textElementToLand)))
333             this.wrapper.find('.current-text-element').removeClass('current-text-element');
334
335         if(textElementToLand) {
336             _markAsCurrent(textElementToLand);
337             if(params.caretTo || !textElementToLand.sameNode(this.getCursor().getPosition().element))
338                 this._moveCaretToTextElement(textElementToLand, params.caretTo); // as method on element?
339             if(!(textElementToLand.sameNode(currentTextElement)))
340                 this.publisher('currentTextElementSet', textElementToLand);
341         } else {
342             document.getSelection().removeAllRanges();
343         }
344
345         if(!(currentNodeElement && currentNodeElement.sameNode(nodeElementToLand))) {
346             _markAsCurrent(nodeElementToLand);
347
348             this.publisher('currentNodeElementSet', nodeElementToLand);
349         }
350     },
351
352     _moveCaretToTextElement: function(element, where) {
353         var range = document.createRange(),
354             node = element.dom().contents()[0];
355
356         if(typeof where !== 'number') {
357             range.selectNodeContents(node);
358         } else {
359             range.setStart(node, where);
360         }
361         
362         var collapseArg = true;
363         if(where === 'end')
364             collapseArg = false;
365         range.collapse(collapseArg);
366         
367         var selection = document.getSelection();
368
369         selection.removeAllRanges();
370         selection.addRange(range);
371         this.wrapper.focus(); // FF requires this for caret to be put where range colllapses, Chrome doesn't.
372     },
373
374     setCursorPosition: function(position) {
375         if(position.element)
376             this._moveCaretToTextElement(position.element, position.offset);
377     },
378
379     toXML: function() {
380         var parent = $('<div>'),
381             parts = this.doc().toXML(0);
382         parent.append(parts);
383         return parent.html();
384     }
385 });
386
387 $.extend(Canvas.prototype.list, {
388     create: function(params) {
389         if(!(params.element1.parent().sameNode(params.element2.parent())))
390             return false;
391             
392         var parent = params.element1.parent();
393         
394         if(parent.childIndex(params.element1) > parent.childIndex(params.element2)) {
395             var tmp = params.element1;
396             params.element1 = params.element2;
397             params.element2 = tmp;
398         }
399         
400         var elementsToWrap = [];
401         
402         var place = 'before';
403         var canvas = this;
404         parent.children().some(function(element) {
405             var _e = element;
406             if(element.sameNode(params.element1))
407                 place = 'inside';
408             if(place === 'inside') {
409                 if(element instanceof documentElement.DocumentTextElement) {
410                     element = element.wrapWithNodeElement({tag: 'div', klass: 'list.item'});
411                     if(element.children()[0].sameNode(params.element1))
412                         params.element1 = element;
413                 }
414                 element.setWlxmlClass('item');
415                 elementsToWrap.push(element);
416             }
417             if(_e.sameNode(params.element2))
418                 return true;
419         });
420         
421         var listElement = documentElement.DocumentNodeElement.create({tag: 'div', klass: 'list-items' + (params.type === 'enum' ? '-enum' : '')});
422         
423         var toret;
424         if(parent.is('list')) {
425             listElement.wrapWithNodeElement({tag: 'div', klass: 'item'});
426             toret = listElement.parent();
427         } else {
428             toret = listElement;
429         }  
430         
431         params.element1.before(toret);
432         
433         elementsToWrap.forEach(function(element) {
434             element.detach();
435             listElement.append(element);
436         });
437     },
438     extractItems: function(params) {
439         params = _.extend({merge: true}, params);
440         var list = params.element1.parent();
441         if(!list.is('list') || !(list.sameNode(params.element2.parent())))
442             return false;
443
444         var idx1 = list.childIndex(params.element1),
445             idx2 = list.childIndex(params.element2),
446             precedingItems = [],
447             extractedItems = [],
448             succeedingItems = [],
449             items = list.children(),
450             listIsNested = list.parent().getWlxmlClass() === 'item',
451             i;
452
453         if(idx1 > idx2) {
454             var tmp = idx1; idx1 = idx2; idx2 = tmp;
455         }
456
457         items.forEach(function(item, idx) {
458             if(idx < idx1)
459                 precedingItems.push(item);
460             else if(idx >= idx1 && idx <= idx2) {
461                 extractedItems.push(item);
462             }
463             else {
464                 succeedingItems.push(item);
465             }
466         });
467
468         var reference = listIsNested ? list.parent() : list;
469         if(succeedingItems.length === 0) {
470             var reference_orig = reference;
471             extractedItems.forEach(function(item) {
472                 reference.after(item);
473                 reference = item;
474                 if(!listIsNested)
475                     item.setWlxmlClass(null);
476             });
477             if(precedingItems.length === 0)
478                 reference_orig.detach();
479         } else if(precedingItems.length === 0) {
480             extractedItems.forEach(function(item) {
481                 reference.before(item);
482                 if(!listIsNested)
483                     item.setWlxmlClass(null);
484             });
485         } else {
486             extractedItems.forEach(function(item) {
487                 reference.after(item);
488                 if(!listIsNested)
489                     item.setWlxmlClass(null);
490                 reference = item;
491             });
492             var secondList = documentElement.DocumentNodeElement.create({tag: 'div', klass:'list-items'}, this),
493                 toAdd = secondList;
494             
495             if(listIsNested) {
496                 toAdd = secondList.wrapWithNodeElement({tag: 'div', klass:'item'});
497             }
498             succeedingItems.forEach(function(item) {
499                 secondList.append(item);
500             });
501
502             reference.after(toAdd);
503         }
504         if(!params.merge && listIsNested) {
505             return this.extractItems({element1: extractedItems[0], element2: extractedItems[extractedItems.length-1]});
506         }
507         return true;
508     },
509     areItemsOfTheSameList: function(params) {
510         var e1 = params.element1,
511             e2 = params.element2;
512         return e1.parent().sameNode(e2.parent())
513             && e1.parent().is('list');
514     }
515 });
516
517
518 var Cursor = function(canvas) {
519     this.canvas = canvas;
520 };
521
522 $.extend(Cursor.prototype, {
523     isSelecting: function() {
524         var selection = window.getSelection();
525         return !selection.isCollapsed;
526     },
527     isSelectingWithinElement: function() {
528         return this.isSelecting() && this.getSelectionStart().element.sameNode(this.getSelectionEnd().element);
529     },
530     isSelectingSiblings: function() {
531         return this.isSelecting() && this.getSelectionStart().element.parent().sameNode(this.getSelectionEnd().element.parent());
532     },
533     getPosition: function() {
534         return this.getSelectionAnchor();
535     },
536     getSelectionStart: function() {
537         return this.getSelectionBoundry('start');
538     },
539     getSelectionEnd: function() {
540         return this.getSelectionBoundry('end');
541     },
542     getSelectionAnchor: function() {
543         return this.getSelectionBoundry('anchor');
544     },
545     getSelectionFocus: function() {
546         return this.getSelectionBoundry('focus');
547     },
548     getSelectionBoundry: function(which) {
549         var selection = window.getSelection(),
550             anchorElement = this.canvas.getDocumentElement(selection.anchorNode),
551             focusElement = this.canvas.getDocumentElement(selection.focusNode);
552         
553         if(anchorElement instanceof documentElement.DocumentNodeElement || focusElement instanceof documentElement.DocumentNodeElement)
554             return {};
555
556         if(which === 'anchor') {
557             return {
558                 element: anchorElement,
559                 offset: selection.anchorOffset,
560                 offsetAtBeginning: selection.anchorOffset === 0,
561                 offsetAtEnd: anchorElement && anchorElement.getText().length === selection.anchorOffset
562             };
563         }
564         if(which === 'focus') {
565             return {
566                 element: focusElement,
567                 offset: selection.focusOffset,
568                 offsetAtBeginning: selection.focusOffset === 0,
569                 offsetAtEnd: focusElement && focusElement.getText().length === selection.focusOffset
570             };
571         }
572         
573         var element,
574             offset;
575
576         if(anchorElement.parent().sameNode(focusElement.parent())) {
577             var parent = anchorElement.parent(),
578                 anchorFirst = parent.childIndex(anchorElement) < parent.childIndex(focusElement);
579             if(anchorFirst) {
580                 if(which === 'start') {
581                     element = anchorElement;
582                     offset = selection.anchorOffset;
583                 }
584                 else if(which === 'end') {
585                     element = focusElement,
586                     offset = selection.focusOffset;
587                 }
588             } else {
589                 if(which === 'start') {
590                     element = focusElement,
591                     offset = selection.focusOffset
592                 }
593                 else if(which === 'end') {
594                     element = anchorElement;
595                     offset = selection.anchorOffset;
596                 }
597             }
598         } else {
599             // TODO: Handle order via https://developer.mozilla.org/en-US/docs/Web/API/Node.compareDocumentPosition
600             if(which === 'start') {
601                 element = anchorElement;
602                 offset = selection.anchorOffset
603             } else {
604                 element = focusElement;
605                 offset = selection.focusOffset
606             }
607         }
608
609         return {
610             element: element,
611             offset: offset,
612             offsetAtBeginning: offset === 0,
613             offsetAtEnd: element.getText().length === offset
614         }
615     }
616 })
617
618 return {
619     fromXML: function(xml, publisher) {
620         return new Canvas(xml, publisher);
621     }
622 };
623
624 });