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