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