editor: canvas fix
[fnpeditor.git] / src / editor / modules / documentCanvas / canvas / canvas.js
1 define([
2 'libs/jquery',
3 'libs/underscore',
4 'libs/backbone',
5 'fnpjs/logging/logging',
6 'modules/documentCanvas/canvas/documentElement',
7 'modules/documentCanvas/canvas/keyboard',
8 'modules/documentCanvas/canvas/utils',
9 'modules/documentCanvas/canvas/wlxmlListener',
10 'modules/documentCanvas/canvas/elementsRegister',
11 'modules/documentCanvas/canvas/genericElement',
12 'modules/documentCanvas/canvas/nullElement',
13 'modules/documentCanvas/canvas/gutter',
14 'modules/documentCanvas/canvas/selection',
15 'modules/documentCanvas/canvas/keyEvent',
16 'libs/text!./canvas.html'
17 ], function($, _, Backbone, logging, documentElement, keyboard, utils, wlxmlListener, ElementsRegister, genericElement, nullElement, gutter, selection, keyEvent, canvasTemplate) {
18     
19 'use strict';
20 /* global document:false, window:false, Node:false, gettext */
21
22 var logger = logging.getLogger('canvas');
23
24 var TextHandler = function(canvas) {this.canvas = canvas; this.buffer = null;};
25 $.extend(TextHandler.prototype, {
26     handle: function(node, text) {
27         this.setText(text, node);
28         // return;
29         // if(!this.node) {
30         //     this.node = node;
31         // }
32         // if(this.node.sameNode(node)) {
33         //     this._ping(text);
34         // } else {
35         //     this.flush();
36         //     this.node = node;
37         //     this._ping(text);
38         // }
39     },
40     _ping: _.throttle(function(text) {
41         this.buffer = text;
42         this.flush();
43     }, 1000),
44     flush: function() {
45         if(this.buffer !== null) {
46             this.setText(this.buffer, this.node);
47             this.buffer = null;
48         }
49     },
50     setText: function(text, node) {
51         //this.canvas.wlxmlDocument.transform('setText', {node:node, text: text});
52         node.document.transaction(function() {
53             node.setText(text);
54         }, {
55             metadata:{
56                 description: gettext('Changing text')
57             }
58         });
59
60     }
61
62 });
63
64
65 var Canvas = function(wlxmlDocument, elements, metadata) {
66     this.metadata = metadata || {};
67     this.elementsRegister = new ElementsRegister(documentElement.DocumentNodeElement, nullElement);
68
69     elements = [
70         {tag: 'section', klass: null, prototype: genericElement},
71         {tag: 'div', klass: null, prototype: genericElement},
72         {tag: 'header', klass: null, prototype: genericElement},
73         {tag: 'span', klass: null, prototype: genericElement},
74         {tag: 'aside', klass: null, prototype: genericElement}
75     ].concat(elements || []);
76
77     (elements).forEach(function(elementDesc) {
78         this.elementsRegister.register(elementDesc);
79     }.bind(this));
80     this.eventBus = _.extend({}, Backbone.Events);
81     
82     this.dom = $(canvasTemplate);
83     this.rootWrapper = this.dom.find('.root-wrapper');
84     
85
86     this.gutter = gutter.create();
87     this.gutterView = new gutter.GutterView(this.gutter);
88     this.dom.find('.view-row').append(this.gutterView.dom);
89     
90     this.wlxmlListener = wlxmlListener.create(this);
91     this.loadWlxmlDocument(wlxmlDocument);
92     this.setupEventHandling();
93     this.textHandler = new TextHandler(this);
94 };
95
96 $.extend(Canvas.prototype, Backbone.Events, {
97
98     getElementOffset: function(element) {
99         return element.dom.offset().top - this.dom.offset().top;
100     },
101
102     loadWlxmlDocument: function(wlxmlDocument) {
103         if(!wlxmlDocument) {
104             return false;
105         }
106
107         this.wlxmlListener.listenTo(wlxmlDocument);
108         this.wlxmlDocument = wlxmlDocument;
109         this.reloadRoot();
110     },
111
112     createElement: function(wlxmlNode) {
113         var Factory;
114         if(wlxmlNode.nodeType === Node.TEXT_NODE) {
115             Factory = documentElement.DocumentTextElement;
116         } else {
117             Factory = this.elementsRegister.getElement({tag: wlxmlNode.getTagName(), klass: wlxmlNode.getClass()});
118         }
119         return new Factory(wlxmlNode, this);
120     },
121
122     getDocumentElement: function(htmlElement) {
123         /* globals HTMLElement, Text */
124         if(!htmlElement || !(htmlElement instanceof HTMLElement || htmlElement instanceof Text)) {
125             return null;
126         }
127         var $element = $(htmlElement);
128         if(htmlElement.nodeType === Node.ELEMENT_NODE && $element.attr('document-node-element') !== undefined) {
129             return $element.data('canvas-element');
130         }
131
132         if(htmlElement.nodeType === Node.TEXT_NODE && $element.parent().attr('document-text-element') !== undefined) {
133             $element = $element.parent();
134         }
135
136         if($element.attr('document-text-element') !== undefined || (htmlElement.nodeType === Node.TEXT_NODE && $element.parent().attr('document-text-element') !== undefined)) {
137             //return DocumentTextElement.fromHTMLElement(htmlElement, canvas);
138             return $element.data('canvas-element');
139         }
140     },
141
142     reloadRoot: function() {
143         if(this.rootElement) {
144             this.rootElement.detach();
145         }
146         this.rootElement = this.createElement(this.wlxmlDocument.root);
147         this.rootWrapper.append(this.rootElement.dom);
148     },
149
150
151     triggerKeyEvent: function(keyEvent, selection) {
152         selection = selection || this.getSelection();
153         if(selection && (selection.type === 'caret' || selection.type === 'textSelection') && selection.toDocumentFragment().isValid()) {
154             keyboard.handleKeyEvent(keyEvent, selection);
155         }
156     },
157
158     setupEventHandling: function() {
159         var canvas = this;
160
161         /* globals document */
162         $(document.body).on('keydown', function(e) {
163             canvas.triggerKeyEvent(keyEvent.fromNativeEvent(e));
164         });
165
166         this.rootWrapper.on('mouseup', function() {
167             canvas.triggerSelectionChanged();
168         });
169
170         var mouseDown;
171         this.rootWrapper.on('mousedown', '[document-node-element], [document-text-element]', function(e) {
172             mouseDown = e.target;
173             canvas.rootWrapper.find('[contenteditable]').attr('contenteditable', null);
174         });
175
176         this.rootWrapper.on('click', '[document-node-element], [document-text-element]', function(e) {
177             var position, element;
178             e.stopPropagation();
179             if(e.originalEvent.detail === 3) {
180                 e.preventDefault();
181                 canvas._moveCaretToTextElement(canvas.getDocumentElement(e.currentTarget), 'whole');
182             } else {
183                 if(mouseDown === e.target) {
184                     element = canvas.getDocumentElement(e.target);
185                     if(element && element.wlxmlNode.nodeType === Node.ELEMENT_NODE) {
186                         if(element.getVerticallyFirstTextElement && !element.getVerticallyFirstTextElement({considerChildren: false})) {
187                             canvas.setCurrentElement(element);
188                             return;
189                         }
190                     }
191                     if(window.getSelection().isCollapsed) {
192                         position = utils.caretPositionFromPoint(e.clientX, e.clientY);
193                         canvas.setCurrentElement(canvas.getDocumentElement(position.textNode), {caretTo: position.offset});
194                     }
195                 }
196             }
197         });
198
199         this.rootWrapper.on('paste', function(e) {
200             e.preventDefault();
201
202             var clipboardData = e.originalEvent.clipboardData;
203             if(!clipboardData || !clipboardData.getData) {
204                 return; // TODO: alert
205             }
206
207             var text = clipboardData.getData('text/plain').replace(/\r?\n|\r/g, ' '),
208                 cursor = canvas.getCursor(),
209                 element = cursor.getPosition().element,
210                 lhs, rhs;
211             
212             if(element && cursor.isWithinElement()) {
213                 lhs = element.getText().substr(0, cursor.getSelectionStart().offset);
214                 rhs = element.getText().substr(cursor.getSelectionEnd().offset);
215                 element.setText(lhs+text+rhs);
216                 canvas.setCurrentElement(element, {caretTo: lhs.length + text.length});
217             } else {
218                 /* jshint noempty:false */
219                 // TODO: alert
220             }
221         });
222
223         /* globals MutationObserver */
224         var observer = new MutationObserver(function(mutations) {
225             mutations.forEach(function(mutation) {
226                 if(canvas.dom[0].contains(mutation.target) && documentElement.DocumentTextElement.isContentContainer(mutation.target)) {
227                     observer.disconnect();
228                     if(mutation.target.data === '') {
229                         mutation.target.data = utils.unicode.ZWS;
230                     }
231                     else if(mutation.oldValue === utils.unicode.ZWS) {
232                         mutation.target.data = mutation.target.data.replace(utils.unicode.ZWS, '');
233                         canvas._moveCaretToTextElement(canvas.getDocumentElement(mutation.target), 'end');
234                     }
235                     observer.observe(canvas.dom[0], config);
236
237                     var textElement = canvas.getDocumentElement(mutation.target),
238                         toSet = mutation.target.data !== utils.unicode.ZWS ? mutation.target.data : '';
239
240                     //textElement.data('wlxmlNode').setText(toSet);
241                     //textElement.data('wlxmlNode').document.transform('setText', {node: textElement.data('wlxmlNode'), text: toSet});
242                     if(textElement.wlxmlNode.getText() !== toSet) {
243                         canvas.textHandler.handle(textElement.wlxmlNode, toSet);
244                     }
245                 }
246             });
247         });
248         var config = { attributes: false, childList: false, characterData: true, subtree: true, characterDataOldValue: true};
249         observer.observe(this.rootWrapper[0], config);
250
251
252         var hoverHandler = function(e) {
253             var el = canvas.getDocumentElement(e.currentTarget),
254                 expose = {
255                     mouseover: true,
256                     mouseout: false
257                 };
258             if(!el) {
259                 return;
260             }
261             e.stopPropagation();
262             if(el instanceof documentElement.DocumentTextElement) {
263                 el = el.parent();
264             }
265             el.updateState({exposed:expose[e.type]});
266         };
267
268         this.rootWrapper.on('mouseover', '[document-node-element], [document-text-element]', hoverHandler);
269         this.rootWrapper.on('mouseout', '[document-node-element], [document-text-element]', hoverHandler);
270
271         this.eventBus.on('elementToggled', function(toggle, element) {
272             if(!toggle) {
273                 canvas.setCurrentElement(canvas.getPreviousTextElement(element));
274             }
275         });
276     },
277
278     view: function() {
279         return this.dom;
280     },
281
282     doc: function() {
283         return this.rootElement;
284     },
285
286     toggleElementHighlight: function(node, toggle) {
287         var element = utils.getElementForNode(node);
288         element.updateState({exposed: toggle});
289     },
290
291     getCursor: function() {
292         return new Cursor(this);
293     },
294
295     
296     getCurrentNodeElement: function() {
297         return this.currentNodeElement;
298     },
299
300     getCurrentTextElement: function() {
301         var htmlElement = this.rootWrapper.find('.current-text-element')[0];
302         if(htmlElement) {
303             return this.getDocumentElement(htmlElement);
304         }
305     },
306
307     getPreviousTextElement: function(relativeToElement, includeInvisible) {
308         return this.getNearestTextElement('above', relativeToElement, includeInvisible);
309     },
310
311     getNextTextElement: function(relativeToElement, includeInvisible) {
312         return this.getNearestTextElement('below', relativeToElement, includeInvisible);
313     },
314
315     getNearestTextElement: function(direction, relativeToElement, includeInvisible) {
316         includeInvisible = includeInvisible !== undefined ? includeInvisible : false;
317         var selector = '[document-text-element]' + (includeInvisible ? '' : ':visible');
318         return this.getDocumentElement(utils.nearestInDocumentOrder(selector, direction, relativeToElement.dom[0]));
319     },
320
321     contains: function(element) {
322         return element && element.dom && element.dom.parents().index(this.rootWrapper) !== -1;
323     },
324
325     triggerSelectionChanged: function() {
326         var s = this.getSelection(),
327             f;
328         if(!s) {
329             return;
330         }
331         this.trigger('selectionChanged', s);
332         f = s.toDocumentFragment();
333
334         if(f && f instanceof f.RangeFragment) {
335             if(this.currentNodeElement) {
336                 this.currentNodeElement.updateState({active: false});
337                 this.currentNodeElement = null;
338             }
339         }
340     },
341
342     getSelection: function() {
343         return selection.fromNativeSelection(this);
344     },
345
346     select: function(fragment) {
347         if(fragment instanceof this.wlxmlDocument.RangeFragment) {
348             this.setCurrentElement(fragment.endNode, {caretTo: fragment.endOffset});
349         } else if(fragment instanceof this.wlxmlDocument.NodeFragment) {
350             var params = {
351                 caretTo: fragment instanceof this.wlxmlDocument.CaretFragment ? fragment.offset : 'start'
352             };
353             this.setCurrentElement(fragment.node, params);
354         } else {
355             logger.debug('Fragment not supported');
356         }
357     },
358
359     setSelection: function(selection) {
360         this.select(this, selection.toDocumentFragment());
361     },
362
363     createSelection: function(params) {
364         return selection.fromParams(this, params);
365     },
366     setCurrentElement: function(element, params) {
367         if(!element) {
368             logger.debug('Invalid element passed to setCurrentElement: ' + element);
369             return;
370         }
371
372         if(!(element instanceof documentElement.DocumentElement)) {
373             element = utils.getElementForNode(element);
374         }
375
376         if(!element || !this.contains(element)) {
377             logger.warning('Cannot set current element: element doesn\'t exist on canvas');
378             return;
379         }
380
381         params = _.extend({caretTo: 'end'}, params);
382         var findFirstDirectTextChild = function(e, nodeToLand) {
383             var byBrowser = this.getCursor().getPosition().element;
384             if(byBrowser && byBrowser.parent().sameNode(nodeToLand)) {
385                 return byBrowser;
386             }
387             return _.isFunction(e.getVerticallyFirstTextElement) ? e.getVerticallyFirstTextElement({considerChildren: false}) : null;
388         }.bind(this);
389         var _markAsCurrent = function(element) {
390             if(element instanceof documentElement.DocumentTextElement) {
391                 this.rootWrapper.find('.current-text-element').removeClass('current-text-element');
392                 element.dom.addClass('current-text-element');
393             } else {
394                 if(this.currentNodeElement) {
395                     this.currentNodeElement.updateState({active: false});
396                 }
397                 element.updateState({active: true});
398                 this.currentNodeElement = element;
399             }
400         }.bind(this);
401
402
403         var isTextElement = element instanceof documentElement.DocumentTextElement,
404             nodeElementToLand = isTextElement ? element.parent() : element,
405             textElementToLand = isTextElement ? element : findFirstDirectTextChild(element, nodeElementToLand),
406             currentTextElement = this.getCurrentTextElement(),
407             currentNodeElement = this.getCurrentNodeElement();
408
409         if(currentTextElement && !(currentTextElement.sameNode(textElementToLand))) {
410             this.rootWrapper.find('.current-text-element').removeClass('current-text-element');
411         }
412
413         if(textElementToLand) {
414             _markAsCurrent(textElementToLand);
415             if((params.caretTo || params.caretTo === 0) || !textElementToLand.sameNode(this.getCursor().getPosition().element)) {
416                 this._moveCaretToTextElement(textElementToLand, params.caretTo); // as method on element?
417             }
418         } else {
419             document.getSelection().removeAllRanges();
420         }
421
422         if(!(currentNodeElement && currentNodeElement.sameNode(nodeElementToLand))) {
423             _markAsCurrent(nodeElementToLand);
424         }
425         this.triggerSelectionChanged();
426     },
427
428     _moveCaretToTextElement: function(element, where) {
429         var range = document.createRange(),
430             node = element.dom.contents()[0];
431
432         if(typeof where !== 'number') {
433             range.selectNodeContents(node);
434         } else {
435             range.setStart(node, Math.min(node.data.length, where));
436         }
437         
438         if(where !== 'whole') {
439             var collapseArg = true;
440             if(where === 'end') {
441                 collapseArg = false;
442             }
443             range.collapse(collapseArg);
444         }
445         var selection = document.getSelection();
446
447         $(node).parent().attr('contenteditable', true);
448         selection.removeAllRanges();
449         selection.addRange(range);
450         $(node).parent().focus(); // FF requires this for caret to be put where range colllapses, Chrome doesn't.
451     },
452
453     setCursorPosition: function(position) {
454         if(position.element) {
455             this._moveCaretToTextElement(position.element, position.offset);
456         }
457     }
458 });
459
460
461 var Cursor = function(canvas) {
462     this.canvas = canvas;
463     this.selection = window.getSelection();
464 };
465
466 $.extend(Cursor.prototype, {
467     sameAs: function(other) {
468         var same = true;
469         if(!other) {
470             return false;
471         }
472
473         ['focusNode', 'focusOffset', 'anchorNode', 'anchorOffset'].some(function(prop) {
474             same = same && this.selection[prop] === other.selection[prop];
475             if(!same) {
476                 return true; // break
477             }
478         }.bind(this));
479
480         return same;
481     },
482     isSelecting: function() {
483         var selection = window.getSelection();
484         return !selection.isCollapsed;
485     },
486     isSelectingWithinElement: function() {
487         return this.isSelecting() && this.getSelectionStart().element.sameNode(this.getSelectionEnd().element);
488     },
489     isWithinElement: function() {
490         return !this.isSelecting() || this.isSelectingWithinElement();
491     },
492     isSelectingSiblings: function() {
493         return this.isSelecting() && this.getSelectionStart().element.parent().sameNode(this.getSelectionEnd().element.parent());
494     },
495     getPosition: function() {
496         return this.getSelectionAnchor();
497     },
498     getSelectionStart: function() {
499         return this.getSelectionBoundry('start');
500     },
501     getSelectionEnd: function() {
502         return this.getSelectionBoundry('end');
503     },
504     getSelectionAnchor: function() {
505         return this.getSelectionBoundry('anchor');
506     },
507     getSelectionFocus: function() {
508         return this.getSelectionBoundry('focus');
509     },
510     getSelectionBoundry: function(which) {
511         /* globals window */
512         var selection = window.getSelection(),
513             anchorElement = this.canvas.getDocumentElement(selection.anchorNode),
514             focusElement = this.canvas.getDocumentElement(selection.focusNode);
515         
516         if((!anchorElement) || (anchorElement instanceof documentElement.DocumentNodeElement) || (!focusElement) || focusElement instanceof documentElement.DocumentNodeElement) {
517             return {};
518         }
519
520         if(which === 'anchor') {
521             return {
522                 element: anchorElement,
523                 offset: selection.anchorOffset,
524                 offsetAtBeginning: selection.anchorOffset === 0 || anchorElement.getText() === '',
525                 offsetAtEnd: selection.anchorNode.data.length === selection.anchorOffset || anchorElement.getText() === ''
526             };
527         }
528         if(which === 'focus') {
529             return {
530                 element: focusElement,
531                 offset: selection.focusOffset,
532                 offsetAtBeginning: selection.focusOffset === 0 || focusElement.getText() === '',
533                 offsetAtEnd: selection.focusNode.data.length === selection.focusOffset || focusElement.getText() === '',
534             };
535         }
536         
537         var getPlaceData = function(anchorFirst) {
538             var element, offset;
539             if(anchorFirst) {
540                 if(which === 'start') {
541                     element = anchorElement;
542                     offset = selection.anchorOffset;
543                 }
544                 else if(which === 'end') {
545                     element = focusElement;
546                     offset = selection.focusOffset;
547                 }
548             } else {
549                 if(which === 'start') {
550                     element = focusElement;
551                     offset = selection.focusOffset;
552                 }
553                 else if(which === 'end') {
554                     element = anchorElement;
555                     offset = selection.anchorOffset;
556                 }
557             }
558             return {element: element, offset: offset};
559         };
560
561         var anchorFirst, placeData, parent;
562
563         if(anchorElement.parent().sameNode(focusElement.parent())) {
564             parent = anchorElement.parent();
565             if(selection.anchorNode === selection.focusNode) {
566                 anchorFirst = selection.anchorOffset <= selection.focusOffset;
567             } else {
568                 anchorFirst = (parent.getFirst(anchorElement, focusElement) === anchorElement);
569             }
570             placeData = getPlaceData(anchorFirst);
571         } else {
572             /*jshint bitwise: false*/
573             anchorFirst = selection.anchorNode.compareDocumentPosition(selection.focusNode) & Node.DOCUMENT_POSITION_FOLLOWING;
574             placeData = getPlaceData(anchorFirst);
575         }
576
577         var nodeLen = (placeData.element.sameNode(focusElement) ? selection.focusNode : selection.anchorNode).length;
578         return {
579             element: placeData.element,
580             offset: placeData.offset,
581             offsetAtBeginning: placeData.offset === 0 || focusElement.getText() === '',
582             offsetAtEnd: nodeLen === placeData.offset || focusElement.getText() === ''
583         };
584     }
585 });
586
587 return {
588     fromXMLDocument: function(wlxmlDocument, elements, metadata) {
589         return new Canvas(wlxmlDocument, elements, metadata);
590     }
591 };
592
593 });