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