js: remake link insertion without document.execCommand
[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 selected_link = get_parent(sel.anchorNode, 'A');
359       if (sel.anchorNode.nodeType == Node.TEXT_NODE) {
360         if (sel.anchorNode.length == sel.anchorOffset && sel.anchorNode.nextSibling.nodeName == 'A') {
361           selected_link = sel.anchorNode.nextSibling;
362         }
363       }
364       var $input = $('input[name=link-target]');
365       $input[0]._range = sel.getRangeAt(0);
366       if (selected_link) {
367         $input[0]._selected_link = selected_link;
368         $input.val(selected_link.href);
369       }
370       $input.addClass('shown');
371       $input.focus();
372       return;
373     }
374     document.execCommand(action, false, param);
375   }
376   function validate_link(ev) {
377     var charCode = typeof ev.which == "number" ? ev.which : ev.keyCode;
378     if (ev.key == "Enter") {
379       var $input = $(this);
380       var range = this._range;
381       var url = $input.val();
382       $input.removeClass('shown');
383       var sel = window.getSelection();
384       sel.addRange(this._range);
385       var selected_link = $input[0]._selected_link;
386       if (url) {
387         if (selected_link) {
388           selected_link.href = url;
389         } else {
390           var $new_link = $('<a></a>', {text: sel.toString(), href: url});
391           this._range.deleteContents();
392           this._range.insertNode($new_link[0]);
393           sel.empty();
394           sel.collapse($new_link[0]);
395           sel.empty();
396         }
397       } else {
398         if (selected_link) {
399           selected_link.replaceWith(document.createTextNode(selected_link.textContent));
400         }
401       }
402       $input.val('');
403       $input[0]._selected_link = null;
404     }
405   }
406   function focusout_link(ev) {
407     var $input = $(this);
408     $input.removeClass('shown');
409     var range = this._range;
410     var sel = window.getSelection();
411     sel.addRange(this._range);
412   }
413
414   function show_inline_style_toolbar(sel) {
415     if (inline_style_toolbar === null) {
416       inline_style_toolbar = $('<div class="inline-style-popup">' +
417                       '<button data-action="italic" data-accel="i"><i>i</i></button>' +
418                       '<button data-action="bold" data-accel="b"><b>b</b></button>' +
419                       '<button data-action="code" data-accel="<">&lt;&gt;</button>' +
420                       '<button data-action="removeFormat" data-accel="m">×</button>' +
421                       '<button data-action="createLink">a</button>' +
422                       '<input name="link-target"/>' +
423                       '</div>');
424       inline_style_toolbar.hide();
425       inline_style_toolbar.insertAfter(document.body);
426       inline_style_toolbar.find('button').on('click', update_style);
427       inline_style_toolbar.find('[name=link-target]').on('keypress', validate_link).on('focusout', focusout_link);
428     }
429     inline_style_toolbar.css('position', 'absolute');
430     var pos = sel.getRangeAt(0).getClientRects()[0];
431     inline_style_toolbar.css('top', pos.top + window.scrollY - 33);
432     inline_style_toolbar.css('left', pos.left + window.scrollX);
433     inline_style_toolbar.show();
434   };
435 }(window, document));
436
437 $(function() {
438   Phylly.init(),
439   $('div[contenteditable]').each(function(i, elem) {Phylly.bind_events(elem)});
440   $('#save').on('click', function() {
441     var text = $('div[contenteditable]')[0].innerHTML;
442     var csrf = $('[name=csrfmiddlewaretoken]').val();
443     $.post('api-save/',
444       { text: text, csrfmiddlewaretoken: csrf}
445     ).fail(function() {
446       $('#save').css('background', 'red');
447     });
448     return false;
449   });
450 });