misc: add image/file upload for live edit (sync with panikdb)
[chloro.git] / chloro / phyll / static / js / chloro.js
1 (function(window, document, undefined) {
2   var Phylly = {
3     BLOCKS: [
4           {name: 'code', tag: 'PRE', klass: 'screen'},
5           {name: 'figure', special: 'img', tag: 'DIV', subtag: true, klass: 'figure'},
6           {name: 'note', tag: 'DIV', subtag: true, klass: 'note'},
7     ],
8     input_event: function(event) {
9       var sel = document.getSelection();
10       var anchorNode = sel.anchorNode;
11       if (sel.anchorNode.contentEditable === 'true' && (
12               sel.anchorNode.innerHTML == '<br>' || !sel.anchorNode.innerHTML)) {
13         // when everything has been removed, add back <p><br></p>
14         var empty_p = document.createElement('P');
15         empty_p.appendChild(document.createElement('BR'));
16         if (anchorNode.childNodes.length) { // lone <br>
17           anchorNode.removeChild(anchorNode.childNodes[0]);
18         }
19         anchorNode.appendChild(empty_p);
20         var range = document.createRange();
21         range.setStart(empty_p, 0);
22         sel.removeAllRanges();
23         sel.addRange(range);
24         return;
25       }
26       if (event.originalEvent.inputType == "insertText") {
27         var main_node = get_contenteditable_subnode(sel.anchorNode);
28         if (main_node.tagName != 'PRE') {
29           var anchorNode = sel.anchorNode;
30           var offset = sel.anchorOffset;
31           var orig_text = sel.anchorNode.data;
32           var text = orig_text;
33           // typography
34           if (event.originalEvent.data === "'") {
35             text = text.slice(0, offset-1) + '’' + text.slice(offset);
36           }
37           if (text != orig_text) {
38             var new_text = document.createTextNode(text);
39             anchorNode.replaceWith(new_text);
40             sel.collapse(new_text, offset);
41           }
42         }
43         return;
44       }
45       if (event.originalEvent.inputType != "insertParagraph") return true;
46       if (sel.anchorNode.tagName == "DIV" && sel.anchorNode.innerHTML == "<br>") {
47         // new empty div got inserted, replace it with a <p>
48         var empty_p = document.createElement('P');
49         empty_p.appendChild(document.createElement('BR'));
50         var empty_div = sel.anchorNode;
51         empty_div.replaceWith(empty_p);
52         var range = document.createRange();
53         range.setStart(empty_p, 0);
54         sel.removeAllRanges();
55         sel.addRange(range);
56       }
57       if (sel.anchorNode.tagName == "LI" && sel.anchorNode.innerHTML == "<br>") {
58         // new empty li got inserted, insert a <p> within
59         var empty_p = document.createElement('P');
60         empty_p.appendChild(document.createElement('BR'));
61         var empty_li = anchorNode;
62         if (empty_li.childNodes.length) { // lone <br>
63           empty_li.removeChild(empty_li.childNodes[0]);
64         }
65         empty_li.appendChild(empty_p);
66         var range = document.createRange();
67         range.setStart(empty_p, 0);
68         sel.removeAllRanges();
69         sel.addRange(range);
70       }
71       var prev_p = sel.anchorNode.previousSibling;
72       if (! prev_p) return;
73       if (prev_p.tagName != 'P') {
74         prev_p = $(prev_p).parents('p')[0];
75         if (! prev_p || prev_p.tagName != 'P') return;
76       }
77       var title_match = prev_p.innerText.match(/^(h[1-6]). /);
78       if (title_match) {
79         var title = document.createElement(title_match[1]);
80         title.innerHTML = prev_p.innerHTML;
81         title.textContent = title.textContent.slice(4);
82         prev_p.replaceWith(title);
83       }
84       return true;
85     },
86
87     init: function() {
88       $(document).on('selectionchange', function(event) {
89         if ($('input[name=link-target].shown').length) {
90           return;
91         }
92         var sel = window.getSelection();
93         if ($(sel.anchorNode).parents('div[contenteditable]').length && sel.toString()) {
94           show_inline_style_toolbar(sel);
95         } else if (inline_style_toolbar) {
96           $(inline_style_toolbar).hide();
97         }
98       });
99       var $image_upload = $('<input type="file" nam="image" id="image-upload">');
100       $image_upload.on('change', upload_image);
101       $image_upload.appendTo(document.body);
102
103       var $document_upload = $('<input type="file" nam="document" id="document-upload">');
104       $document_upload.on('change', upload_document);
105       $document_upload.appendTo(document.body);
106
107       document.execCommand('defaultParagraphSeparator', false, 'p');
108       $(document).on('click', 'div.figure span.empty', function() {
109         window.active_figure = this.parentNode;
110         $('#image-upload').trigger('click');
111         return true;
112       });
113       $(document).on('click', 'div.document span.empty', function() {
114         window.active_document = this.parentNode;
115         $('#document-upload').trigger('click');
116         return true;
117       });
118     },
119
120     off: function() {
121       $('#image-upload').remove();
122       $('#document-upload').remove();
123       if (block_style_toolbar) { block_style_toolbar.hide(); }
124       if (inline_style_toolbar) { inline_style_toolbar.hide(); }
125       $(document).off('selectionchange');
126     },
127
128     window_keypress: function(ev) {
129       if (inline_style_toolbar && inline_style_toolbar.is(':visible')) {
130         if (event.ctrlKey || event.metaKey) {
131           var key = String.fromCharCode(event.which).toLowerCase();
132           var button = inline_style_toolbar.find('[data-accel="' + key + '"]').first();
133           if (button.length) {
134             button.trigger('click');
135             ev.preventDefault();
136           }
137         }
138       }
139     },
140
141     bind_events: function(elem) {
142       $(elem).on('input', Phylly.input_event);
143       $(elem).on('keyup click', update_block_style_toolbar);
144       $(window).on('keydown', this.window_keypress);
145     },
146
147     unbind_events: function(elem) {
148       $(elem).off('input');
149       $(elem).off('keyup click');
150       $(window).off('keydown', this.window_keypress);
151     },
152
153   }
154   window.Phylly = Phylly;
155
156   function upload_image() {
157     if ($(this).prop('files').length > 0) {
158       var file = $(this).prop('files')[0];
159       var params = new FormData();
160       params.append('upload', file);
161       $.post({url: '/ajax/upload/', processData: false, data: params, contentType: false}).success(function(data) {
162         var img = document.createElement('IMG');
163         img.src = data.url;
164         if (data.orig_url) {
165           img.setAttribute('data-orig-url', data.orig_url);
166         }
167         $(window.active_figure).empty().append(img);
168       });
169     }
170   }
171
172   function upload_document() {
173     if ($(this).prop('files').length > 0) {
174       var file = $(this).prop('files')[0];
175       var params = new FormData();
176       params.append('upload', file);
177       $.post({url: '/ajax/upload/', processData: false, data: params, contentType: false}).success(function(data) {
178         var doc_link = document.createElement('A');
179         doc_link.className = 'button';
180         doc_link.textContent = 'Télécharger ' + data.filename;
181         doc_link.href = data.url;
182         $(window.active_document).empty().append(doc_link);
183       });
184     }
185   }
186
187   function get_contenteditable_subnode(node) {
188     if (node === null) return null;
189     if (node.contentEditable === 'true') return node;  // but we shouldn't arrive at root
190     if (node.parentNode.contentEditable === 'true') return node;
191     return get_contenteditable_subnode(node.parentNode);
192   }
193   function get_parent(node, type) {
194    if (node === null) return null;
195    if (node.tagName == type) return node;
196    return get_parent(node.parentNode, type);
197   }
198   function get_active_block(node) {
199     var main_node = get_contenteditable_subnode(node);
200     if (main_node === null) return null;
201     for (const block of Phylly.BLOCKS) {
202       if (main_node.tagName === block.tag && main_node.classList.contains(block.klass))
203         return block;
204     }
205     return null;
206   }
207
208   var block_style_toolbar = null;
209   function block_style() {
210     var sel = window.getSelection();
211     var current_anchor = sel.anchorNode;
212     if (this.action_block.special == 'img') {
213       action = 'insertHTML';
214       param = '<div class="figure"><span class="empty"></span></div><p id="new-p"></p>';
215       document.execCommand(action, false, param);
216       current_anchor = $('#new-p')[0];
217       $(current_anchor).attr('id', null);
218       var range = document.createRange();
219       range.setStart(current_anchor, 0);
220       sel.removeAllRanges();
221       sel.addRange(range);
222       update_block_style_toolbar();
223       return;
224     }
225     if (this.action_block.special == 'doc') {
226       action = 'insertHTML';
227       param = '<div class="document"><span class="empty"></span></div><p id="new-p"></p>';
228       document.execCommand(action, false, param);
229       current_anchor = $('#new-p')[0];
230       $(current_anchor).attr('id', null);
231       var range = document.createRange();
232       range.setStart(current_anchor, 0);
233       sel.removeAllRanges();
234       sel.addRange(range);
235       update_block_style_toolbar();
236       return;
237     }
238     if (this.action_block.special == 'list') {
239       if (this.classList.contains('on')) { // toggle off
240         var main_node = get_contenteditable_subnode(sel.anchorNode);
241         var li = get_parent(sel.anchorNode, 'LI');
242         for (var i=li.childNodes.length; i>0; i--) {
243           var child = li.childNodes[i-1];
244           main_node.insertAdjacentElement('afterend', child);
245           var range = document.createRange();
246           range.setStart(child, 0);
247           sel.removeAllRanges();
248           sel.addRange(range);
249         }
250         li.remove();
251         update_block_style_toolbar();
252       } else {
253         var current_node = sel.anchorNode;
254         var ul = document.createElement('UL');
255         ul.className = 'list';
256         var li = document.createElement('LI');
257         ul.appendChild(li);
258         sel.anchorNode.parentNode.insertBefore(ul, current_node);
259         li.appendChild(current_node);
260         var range = document.createRange();
261         range.setStart(current_node, 0);
262         sel.removeAllRanges();
263         sel.addRange(range);
264       }
265       return;
266     }
267     if (this.classList.contains('on')) { // toggle off
268       if (this.action_block.subtag) {
269         // unwrap
270         var main_node = get_contenteditable_subnode(current_anchor);
271         $(current_anchor).detach().insertAfter(main_node);
272       } else {
273         document.execCommand('formatBlock', false, 'p');
274         current_anchor = sel.anchorNode;
275       }
276     } else {
277       action = this.action_block.subtag || this.action_block.tag;
278       if (this.action_block.subtag) {
279         // enclose current tag into a new parent;
280         var new_parent = document.createElement(this.action_block.tag);
281         new_parent.className = this.action_block.klass;
282         $(current_anchor).wrap(new_parent);
283       } else {
284         document.execCommand('formatBlock', false, this.action_block.tag);
285         sel.anchorNode.className = this.action_block.klass;
286         current_anchor = sel.anchorNode;
287       }
288     }
289     var range = document.createRange();
290     range.setStart(current_anchor, 0);
291     sel.removeAllRanges();
292     sel.addRange(range);
293     update_block_style_toolbar();
294   }
295   function update_block_style_toolbar() {
296     var sel = window.getSelection();
297     if (! ((sel.anchorNode instanceof Element && (sel.anchorOffset == 0 && sel.isCollapsed)) || get_active_block(sel.anchorNode))) {
298       if (block_style_toolbar) {
299         $(block_style_toolbar).hide();
300       }
301       return true;
302     }
303     if (block_style_toolbar === null) {
304       block_style_toolbar = $('<div class="block-style-popup"></div>');
305       for (const block of Phylly.BLOCKS) {
306         var button = document.createElement('button');
307         button.action_block = block;
308         button.dataset.action = block.name;
309         button.textContent = block.name;
310         block_style_toolbar.append(button);
311       }
312       block_style_toolbar.hide();
313       block_style_toolbar.insertAfter(document.body);
314       block_style_toolbar.find('button').on('click', block_style);
315     }
316     block_style_toolbar.css('position', 'absolute');
317     var block = get_active_block(sel.anchorNode);
318     block_style_toolbar.find('button').removeClass('on');
319     if (block) {
320       block_style_toolbar.find('[data-action=' + block.name + ']').addClass('on');
321       block_style_toolbar.addClass('selected');
322     } else {
323       block_style_toolbar.removeClass('selected');
324     }
325     var anchor = get_contenteditable_subnode(sel.anchorNode);
326     var pos = $(anchor).offset();
327     block_style_toolbar.css('top', pos.top - 33);
328     block_style_toolbar.css('left', pos.left);
329     block_style_toolbar.show();
330     return true;
331   }
332
333   var inline_style_toolbar = null;
334   function update_style() {
335     var action = $(this).data('action');
336     var param = null;
337     if (action == 'code') {
338       action = 'insertHTML';
339       param = $('<code></code>', {text: window.getSelection().toString()})[0].outerHTML;
340     }
341     if (action == 'wiki') {
342       action = 'insertHTML';
343       var text = window.getSelection().toString();
344       var $new_link = $('<a></a>', {text: text, href: '#tbd'});
345       var request_id = Math.floor(Math.random() * 10000);
346       $new_link.attr('data-request-id', request_id);
347       var params = {};
348       params.title = text;
349       params.request_id = request_id;
350       $.post('/wiki/ajax/newpage/', params).success(function(data) {
351         $('a[data-request-id=' + data.request_id + ']').attr('href', data.url).removeAttr('data-request-id');
352       });
353       param = $new_link[0].outerHTML;
354     }
355     if (action == 'createLink') {
356       var sel = window.getSelection();
357       var $input = $('input[name=link-target]');
358       $input[0]._range = sel.getRangeAt(0);
359       if (sel.anchorNode instanceof Element) {
360         var elem = sel.anchorNode.childNodes[sel.anchorOffset];
361         if (elem.tagName == 'A') {
362           $input.val(elem.href);
363         }
364       }
365       $input.addClass('shown');
366       $input.focus();
367       return;
368     }
369     document.execCommand(action, false, param);
370   }
371   function validate_link(ev) {
372     var charCode = typeof ev.which == "number" ? ev.which : ev.keyCode;
373     if (ev.key == "Enter") {
374       var $input = $(this);
375       var range = this._range;
376       var url = $input.val();
377       $input.removeClass('shown');
378       var sel = window.getSelection();
379       sel.addRange(this._range);
380       if (url) {
381         document.execCommand('createLink', false, url);
382       } else {
383         document.execCommand('unlink', false, null);
384       }
385       sel.empty();
386       $input.val('');
387     }
388   }
389   function focusout_link(ev) {
390     var $input = $(this);
391     $input.removeClass('shown');
392     var range = this._range;
393     var sel = window.getSelection();
394     sel.addRange(this._range);
395   }
396
397   function show_inline_style_toolbar(sel) {
398     if (inline_style_toolbar === null) {
399       inline_style_toolbar = $('<div class="inline-style-popup">' +
400                       '<button data-action="italic" data-accel="i"><i>i</i></button>' +
401                       '<button data-action="bold" data-accel="b"><b>b</b></button>' +
402                       '<button data-action="code" data-accel="<">&lt;&gt;</button>' +
403                       '<button data-action="removeFormat" data-accel="m">×</button>' +
404                       '<button data-action="createLink">a</button>' +
405                       '<input name="link-target"/>' +
406                       '</div>');
407       inline_style_toolbar.hide();
408       inline_style_toolbar.insertAfter(document.body);
409       inline_style_toolbar.find('button').on('click', update_style);
410       inline_style_toolbar.find('[name=link-target]').on('keypress', validate_link).on('focusout', focusout_link);
411     }
412     inline_style_toolbar.css('position', 'absolute');
413     var pos = sel.getRangeAt(0).getClientRects()[0];
414     inline_style_toolbar.css('top', pos.top + window.scrollY - 33);
415     inline_style_toolbar.css('left', pos.left + window.scrollX);
416     inline_style_toolbar.show();
417   };
418 }(window, document));
419
420 $(function() {
421   Phylly.init(),
422   $('div[contenteditable]').each(function(i, elem) {Phylly.bind_events(elem)});
423   $('#save').on('click', function() {
424     var text = $('div[contenteditable]')[0].innerHTML;
425     var csrf = $('[name=csrfmiddlewaretoken]').val();
426     $.post('api-save/',
427       { text: text, csrfmiddlewaretoken: csrf}
428     ).fail(function() {
429       $('#save').css('background', 'red');
430     });
431     return false;
432   });
433 });