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