editor: fix cursor out of text node handling Chrome hack
[fnpeditor.git] / src / editor / modules / documentCanvas / canvas / documentElement.js
1 define([
2 'libs/jquery',
3 'libs/underscore',
4 'modules/documentCanvas/canvas/utils',
5 'modules/documentCanvas/canvas/widgets',
6 'modules/documentCanvas/canvas/wlxmlManagers'
7 ], function($, _, utils, widgets, wlxmlManagers) {
8     
9 'use strict';
10 /* global Node:false, document:false */
11
12
13 // DocumentElement represents a text or an element node from WLXML document rendered inside Canvas
14 var DocumentElement = function(htmlElement, canvas) {
15     if(arguments.length === 0) {
16         return;
17     }
18     this.canvas = canvas;
19     this._setupDOMHandler(htmlElement);
20 };
21
22
23 var elementTypeFromWlxmlNode = function(wlxmlNode) {
24     return wlxmlNode.nodeType === Node.TEXT_NODE ? DocumentTextElement : DocumentNodeElement;
25 };
26
27 $.extend(DocumentElement, {
28     create: function(node, canvas) {
29         return elementTypeFromWlxmlNode(node).create(node, canvas);
30     },
31
32     fromHTMLElement: function(htmlElement, canvas) {
33         var $element = $(htmlElement);
34         if(htmlElement.nodeType === Node.ELEMENT_NODE && $element.attr('document-node-element') !== undefined) {
35             return DocumentNodeElement.fromHTMLElement(htmlElement, canvas);
36         }
37         if($element.attr('document-text-element') !== undefined || (htmlElement.nodeType === Node.TEXT_NODE && $element.parent().attr('document-text-element') !== undefined)) {
38             return DocumentTextElement.fromHTMLElement(htmlElement, canvas);
39         }
40         return undefined;
41     }
42 });
43
44 $.extend(DocumentElement.prototype, {
45     _setupDOMHandler: function(htmlElement) {
46         this.$element = $(htmlElement);
47     },
48     bound: function() {
49         return $.contains(document.documentElement, this.dom()[0]);
50     },
51     dom: function() {
52         return this.$element;
53     },
54     data: function() {
55         var dom = this.dom(),
56             args = Array.prototype.slice.call(arguments, 0);
57         if(args.length === 2 && args[1] === undefined) {
58             return dom.removeData(args[0]);
59         }
60         return dom.data.apply(dom, arguments);
61     },
62     parent: function() {
63         var parents = this.$element.parents('[document-node-element]');
64         if(parents.length) {
65             return DocumentElement.fromHTMLElement(parents[0], this.canvas);
66         }
67         return null;
68     },
69
70     parents: function() {
71         var parents = [],
72             parent = this.parent();
73         while(parent) {
74             parents.push(parent);
75             parent = parent.parent();
76         }
77         return parents;
78     },
79
80     sameNode: function(other) {
81         return other && (typeof other === typeof this) && other.dom()[0] === this.dom()[0];
82     },
83
84     markAsCurrent: function() {
85         this.canvas.markAsCurrent(this);
86     },
87
88     getVerticallyFirstTextElement: function() {
89         var toret;
90         this.children().some(function(child) {
91             if(!child.isVisible()) {
92                 return false; // continue
93             }
94             if(child instanceof DocumentTextElement) {
95                 toret = child;
96                 return true; // break
97             } else {
98                 toret = child.getVerticallyFirstTextElement();
99                 if(toret) {
100                     return true; // break
101                 }
102             }
103         });
104         return toret;
105     },
106
107     getPreviousTextElement: function(includeInvisible) {
108         return this.getNearestTextElement('above', includeInvisible);
109     },
110
111     getNextTextElement: function(includeInvisible) {
112         return this.getNearestTextElement('below', includeInvisible);
113     },
114
115     getNearestTextElement: function(direction, includeInvisible) {
116         includeInvisible = includeInvisible !== undefined ? includeInvisible : false;
117         var selector = '[document-text-element]' + (includeInvisible ? '' : ':visible');
118         return this.canvas.getDocumentElement(utils.nearestInDocumentOrder(selector, direction, this.dom()[0]));
119     },
120
121     isVisible: function() {
122         return this instanceof DocumentTextElement || this.getWlxmlTag() !== 'metadata';
123     },
124
125     isInsideList: function() {
126         return this.parents().some(function(parent) {
127             return parent.is('list');
128         });
129     },
130
131     exec: function(method) {
132         var manager = this.data('_wlxmlManager');
133         if(manager[method]) {
134             return manager[method].apply(manager, Array.prototype.slice.call(arguments, 1));
135         }
136     }
137 });
138
139
140 // DocumentNodeElement represents an element node from WLXML document rendered inside Canvas
141 var DocumentNodeElement = function(htmlElement, canvas) {
142     DocumentElement.call(this, htmlElement, canvas);
143 };
144
145 $.extend(DocumentNodeElement, {
146     create: function(wlxmlNode, canvas) {
147         return this.fromHTMLElement(this.createDOM(wlxmlNode, canvas)[0], canvas);
148     },
149
150     fromHTMLElement: function(htmlElement, canvas) {
151         return new this(htmlElement, canvas);
152     },
153
154     createDOM: function(wlxmlNode, canvas) {
155         var dom = $('<div>')
156                 .attr('document-node-element', ''),
157             widgetsContainer = $('<div>')
158                 .addClass('canvas-widgets')
159                 .attr('contenteditable', false),
160             container = $('<div>')
161                 .attr('document-element-content', '');
162         
163         dom.append(widgetsContainer, container);
164         // Make sure widgets aren't navigable with arrow keys
165         widgetsContainer.find('*').add(widgetsContainer).attr('tabindex', -1);
166
167         var element = this.fromHTMLElement(dom[0], canvas);
168
169         element.data('wlxmlNode', wlxmlNode);
170         wlxmlNode.setData('canvasElement', element);
171
172         element.setWlxml({tag: wlxmlNode.getTagName(), klass: wlxmlNode.getClass()});
173
174         wlxmlNode.contents().forEach(function(node) {
175             container.append(DocumentElement.create(node).dom());
176         }.bind(this));
177
178         return dom;
179     }
180
181 });
182
183 var manipulate = function(e, params, action) {
184     var element;
185     if(params instanceof DocumentElement) {
186         element = params;
187     } else {
188         element = DocumentElement.create(params);
189     }
190     var target = (action === 'append' || action === 'prepend') ? e._container() : e.dom();
191     target[action](element.dom());
192     return element;
193 };
194
195 DocumentNodeElement.prototype = new DocumentElement();
196
197
198 $.extend(DocumentNodeElement.prototype, {
199     _container: function() {
200         return this.dom().children('[document-element-content]');
201     },
202     detach: function() {
203         this.dom().detach();
204         this.canvas = null;
205         return this;
206     },
207     append: function(params) {
208         if(params.tag !== 'span') {
209             this.data('orig-end', undefined);
210         }
211         return manipulate(this, params, 'append');
212     },
213     prepend: function(params) {
214         return manipulate(this, params, 'prepend');
215     },
216     before: function(params) {
217         return manipulate(this, params, 'before');
218
219     },
220     after: function(params) {
221         return manipulate(this, params, 'after');
222     },
223     children: function() {
224         var toret = [];
225         if(this instanceof DocumentTextElement) {
226             return toret;
227         }
228
229
230         var elementContent = this._container().contents();
231         var element = this;
232         elementContent.each(function(idx) {
233             var childElement = DocumentElement.fromHTMLElement(this, element.canvas);
234             if(childElement === undefined) {
235                 return true;
236             }
237             if(idx === 0 && elementContent.length > 1 && elementContent[1].nodeType === Node.ELEMENT_NODE && (childElement instanceof DocumentTextElement) && $.trim($(this).text()) === '') {
238                 return true;
239             }
240             if(idx > 0 && childElement instanceof DocumentTextElement) {
241                 if(toret[toret.length-1] instanceof DocumentNodeElement && $.trim($(this).text()) === '') {
242                     return true;
243                 }
244             }
245             toret.push(childElement);
246         });
247         return toret;
248     },
249     childIndex: function(child) {
250         var children = this.children(),
251             toret = null;
252         children.forEach(function(c, idx) {
253             if(c.sameNode(child)) {
254                 toret = idx;
255                 return false;
256             }
257         });
258         return toret;
259     },
260     getWlxmlTag: function() {
261         return this._container().attr('wlxml-tag');
262     },
263     setWlxmlTag: function(tag) {
264         if(tag === this.getWlxmlTag()) {
265             return;
266         }
267
268         this._container().attr('wlxml-tag', tag);
269         if(!this.__updatingWlxml) {
270             this._updateWlxmlManager();
271         }
272     },
273     getWlxmlClass: function() {
274         var klass = this._container().attr('wlxml-class');
275         if(klass) {
276             return klass.replace(/-/g, '.');
277         }
278         return undefined;
279     },
280     setWlxmlClass: function(klass) {
281         if(klass === this.getWlxmlClass()) {
282             return;
283         }
284         if(klass) {
285             this._container().attr('wlxml-class', klass.replace(/\./g, '-'));
286         }
287         else {
288             this._container().removeAttr('wlxml-class');
289         }
290         if(!this.__updatingWlxml) {
291             this._updateWlxmlManager();
292         }
293     },
294     setWlxml: function(params) {
295         this.__updatingWlxml = true;
296         if(params.tag !== undefined) {
297             this.setWlxmlTag(params.tag);
298         }
299         if(params.klass !== undefined) {
300             this.setWlxmlClass(params.klass);
301         }
302         this._updateWlxmlManager();
303         this.__updatingWlxml = false;
304     },
305     _updateWlxmlManager: function() {
306         var manager = wlxmlManagers.getFor(this);
307         this.data('_wlxmlManager', manager);
308         manager.setup();
309     },
310     is: function(what) {
311         if(what === 'list' && _.contains(['list.items', 'list.items.enum'], this.getWlxmlClass())) {
312             return true;
313         }
314         return false;
315     },
316     toggleLabel: function(toggle) {
317         var displayCss = toggle ? 'inline-block' : 'none';
318         var label = this.dom().children('.canvas-widgets').find('.canvas-widget-label');
319         label.css('display', displayCss);
320         this.toggleHighlight(toggle);
321     },
322
323     toggleHighlight: function(toggle) {
324         this._container().toggleClass('highlighted-element', toggle);
325     },
326
327     toggle: function(toggle) {
328         var mng = this.data('_wlxmlManager');
329         if(mng) {
330             mng.toggle(toggle);
331         }
332     }
333 });
334
335
336 // DocumentNodeElement represents a text node from WLXML document rendered inside Canvas
337 var DocumentTextElement = function(htmlElement, canvas) {
338     DocumentElement.call(this, htmlElement, canvas);
339 };
340
341 $.extend(DocumentTextElement, {
342     createDOM: function(wlxmlTextNode) {
343         var dom = $('<div>')
344             .attr('document-text-element', '')
345             .text(wlxmlTextNode.getText() || utils.unicode.ZWS),
346         element = this.fromHTMLElement(dom[0], this);
347         element.data('wlxmlNode', wlxmlTextNode);
348         return dom;
349     },
350
351     create: function(wlxmlTextNode, canvas) {
352         return this.fromHTMLElement(this.createDOM(wlxmlTextNode)[0], canvas);
353     },
354
355     fromHTMLElement: function(htmlElement, canvas) {
356         return new this(htmlElement, canvas);
357     },
358     isContentContainer: function(htmlElement) {
359         return htmlElement.nodeType === Node.TEXT_NODE && $(htmlElement).parent().is('[document-text-element]');
360     }
361 });
362
363 DocumentTextElement.prototype = new DocumentElement();
364
365 $.extend(DocumentTextElement.prototype, {
366     _setupDOMHandler: function(htmlElement) {
367         var $element = $(htmlElement);
368         if(htmlElement.nodeType === Node.TEXT_NODE) {
369             this.$element = $element.parent();
370         }
371         else {
372             this.$element = $element;
373         }
374     },
375     detach: function() {
376         this.dom().detach();
377         this.canvas = null;
378         return this;
379     },
380     setText: function(text) {
381         this.dom().contents()[0].data = text;
382     },
383     getText: function(options) {
384         options = _.extend({raw: false}, options || {});
385         var toret = this.dom().text();
386         if(!options.raw) {
387             toret = toret.replace(utils.unicode.ZWS, '');
388         }
389         return toret;
390     },
391     isEmpty: function() {
392         // Having at least Zero Width Space is guaranteed be Content Observer
393         return this.dom().contents()[0].data === utils.unicode.ZWS;
394     },
395     after: function(params) {
396         if(params instanceof DocumentTextElement || params.text) {
397             return false;
398         }
399         var element;
400         if(params instanceof DocumentNodeElement) {
401             element = params;
402         } else {
403             element = DocumentElement.create(params, this.canvas);
404         }
405         this.dom().wrap('<div>');
406         this.dom().parent().after(element.dom());
407         this.dom().unwrap();
408         return element;
409     },
410     before: function(params) {
411         if(params instanceof DocumentTextElement || params.text) {
412             return false;
413         }
414         var element;
415         if(params instanceof DocumentNodeElement) {
416             element = params;
417         } else {
418             element = DocumentNodeElement.create(params, this.canvas);
419         }
420         this.dom().wrap('<div>');
421         this.dom().parent().before(element.dom());
422         this.dom().unwrap();
423         return element;
424     },
425
426     divide: function(params) {
427         var myText = this.getText();
428
429         if(params.offset === myText.length) {
430             return this.after(params);
431         }
432         if(params.offset === 0) {
433             return this.before(params);
434         }
435
436         var lhsText = myText.substr(0, params.offset),
437             rhsText = myText.substr(params.offset),
438             newElement = DocumentNodeElement.create({tag: params.tag, klass: params.klass}, this.canvas),
439             rhsTextElement = DocumentTextElement.create({text: rhsText});
440
441         this.setText(lhsText);
442         this.after(newElement);
443         newElement.after(rhsTextElement);
444         return newElement;
445     },
446
447     toggleHighlight: function() {
448         // do nothing for now
449     }
450 });
451
452 return {
453     DocumentElement: DocumentElement,
454     DocumentNodeElement: DocumentNodeElement,
455     DocumentTextElement: DocumentTextElement
456 };
457
458 });