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