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