misc: add image/file upload for live edit (sync with panikdb)
authorFrédéric Péters <fpeters@0d.be>
Wed, 15 Jul 2020 09:14:23 +0000 (11:14 +0200)
committerFrédéric Péters <fpeters@0d.be>
Wed, 15 Jul 2020 09:14:23 +0000 (11:14 +0200)
chloro/phyll/static/css/style.scss
chloro/phyll/static/js/chloro.js
chloro/phyll/urls.py
chloro/phyll/views.py
chloro/settings.py

index 287acd9..164c2df 100644 (file)
@@ -247,3 +247,28 @@ button#save {
        position: sticky;
        bottom: 10px;
 }
+
+main.post {
+       [contenteditable=true] div.figure {
+               cursor: pointer;
+       }
+       div.figure {
+               text-align: center;
+               line-height: initial;
+               img {
+                       max-width: 90%;
+                       max-height: 70vh;
+               }
+               span.empty::before {
+                       min-height: 50px;
+                       margin: 0 auto;
+                       display: block;
+                       width: 90%;
+                       background: #eee;
+                       padding: 1rem;
+                       font-size: 200px;
+                       content: "(image)";
+                       color: #aaa;
+               }
+       }
+}
index 6b831ac..b70fa29 100644 (file)
         sel.addRange(range);
         return;
       }
+      if (event.originalEvent.inputType == "insertText") {
+        var main_node = get_contenteditable_subnode(sel.anchorNode);
+        if (main_node.tagName != 'PRE') {
+          var anchorNode = sel.anchorNode;
+          var offset = sel.anchorOffset;
+          var orig_text = sel.anchorNode.data;
+          var text = orig_text;
+          // typography
+          if (event.originalEvent.data === "'") {
+            text = text.slice(0, offset-1) + '’' + text.slice(offset);
+          }
+          if (text != orig_text) {
+            var new_text = document.createTextNode(text);
+            anchorNode.replaceWith(new_text);
+            sel.collapse(new_text, offset);
+          }
+        }
+        return;
+      }
       if (event.originalEvent.inputType != "insertParagraph") return true;
       if (sel.anchorNode.tagName == "DIV" && sel.anchorNode.innerHTML == "<br>") {
         // new empty div got inserted, replace it with a <p>
       $image_upload.on('change', upload_image);
       $image_upload.appendTo(document.body);
 
+      var $document_upload = $('<input type="file" nam="document" id="document-upload">');
+      $document_upload.on('change', upload_document);
+      $document_upload.appendTo(document.body);
+
+      document.execCommand('defaultParagraphSeparator', false, 'p');
       $(document).on('click', 'div.figure span.empty', function() {
         window.active_figure = this.parentNode;
         $('#image-upload').trigger('click');
         return true;
       });
+      $(document).on('click', 'div.document span.empty', function() {
+        window.active_document = this.parentNode;
+        $('#document-upload').trigger('click');
+        return true;
+      });
     },
 
     off: function() {
       $('#image-upload').remove();
+      $('#document-upload').remove();
       if (block_style_toolbar) { block_style_toolbar.hide(); }
       if (inline_style_toolbar) { inline_style_toolbar.hide(); }
       $(document).off('selectionchange');
     },
 
+    window_keypress: function(ev) {
+      if (inline_style_toolbar && inline_style_toolbar.is(':visible')) {
+        if (event.ctrlKey || event.metaKey) {
+          var key = String.fromCharCode(event.which).toLowerCase();
+          var button = inline_style_toolbar.find('[data-accel="' + key + '"]').first();
+          if (button.length) {
+            button.trigger('click');
+            ev.preventDefault();
+          }
+        }
+      }
+    },
+
     bind_events: function(elem) {
       $(elem).on('input', Phylly.input_event);
       $(elem).on('keyup click', update_block_style_toolbar);
+      $(window).on('keydown', this.window_keypress);
     },
 
     unbind_events: function(elem) {
       $(elem).off('input');
       $(elem).off('keyup click');
+      $(window).off('keydown', this.window_keypress);
     },
 
   }
     if ($(this).prop('files').length > 0) {
       var file = $(this).prop('files')[0];
       var params = new FormData();
-      params.append('image', file);
-      $.post({url: '/wiki/ajax/image/', processData: false, data: params, contentType: false}).success(function(data) {
+      params.append('upload', file);
+      $.post({url: '/ajax/upload/', processData: false, data: params, contentType: false}).success(function(data) {
         var img = document.createElement('IMG');
         img.src = data.url;
         if (data.orig_url) {
     }
   }
 
+  function upload_document() {
+    if ($(this).prop('files').length > 0) {
+      var file = $(this).prop('files')[0];
+      var params = new FormData();
+      params.append('upload', file);
+      $.post({url: '/ajax/upload/', processData: false, data: params, contentType: false}).success(function(data) {
+        var doc_link = document.createElement('A');
+        doc_link.className = 'button';
+        doc_link.textContent = 'Télécharger ' + data.filename;
+        doc_link.href = data.url;
+        $(window.active_document).empty().append(doc_link);
+      });
+    }
+  }
+
   function get_contenteditable_subnode(node) {
     if (node === null) return null;
     if (node.contentEditable === 'true') return node;  // but we shouldn't arrive at root
       sel.removeAllRanges();
       sel.addRange(range);
       update_block_style_toolbar();
-
+      return;
+    }
+    if (this.action_block.special == 'doc') {
+      action = 'insertHTML';
+      param = '<div class="document"><span class="empty"></span></div><p id="new-p"></p>';
+      document.execCommand(action, false, param);
+      current_anchor = $('#new-p')[0];
+      $(current_anchor).attr('id', null);
+      var range = document.createRange();
+      range.setStart(current_anchor, 0);
+      sel.removeAllRanges();
+      sel.addRange(range);
+      update_block_style_toolbar();
       return;
     }
     if (this.action_block.special == 'list') {
   function show_inline_style_toolbar(sel) {
     if (inline_style_toolbar === null) {
       inline_style_toolbar = $('<div class="inline-style-popup">' +
-                      '<button data-action="italic"><i>i</i></button>' +
-                      '<button data-action="bold"><b>b</b></button>' +
-                      '<button data-action="code">&lt;&gt;</button>' +
-                      '<button data-action="removeFormat">×</button>' +
+                      '<button data-action="italic" data-accel="i"><i>i</i></button>' +
+                      '<button data-action="bold" data-accel="b"><b>b</b></button>' +
+                      '<button data-action="code" data-accel="<">&lt;&gt;</button>' +
+                      '<button data-action="removeFormat" data-accel="m">×</button>' +
                       '<button data-action="createLink">a</button>' +
                       '<input name="link-target"/>' +
                       '</div>');
index d49525e..34db0b4 100644 (file)
@@ -39,6 +39,7 @@ urlpatterns = [
         staff_member_required(views.NoteDeleteView.as_view(), login_url='login'),
     ),
     url(r'^(?P<slug>[\w:-]+)/api-save/$', staff_member_required(views.NoteApiSaveView.as_view())),
+    url(r'^ajax/upload/$', staff_member_required(views.ajax_upload)),
     url(r'^new-note/$', staff_member_required(views.NoteAddView.as_view(), login_url='login')),
     url(r'^feeds/(?P<sub>[\w:-]+)/atom$', views.AtomFeed()),
     url(r'^feed/atom$', views.AtomFeed()),
index a03c93f..2e77fc9 100644 (file)
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import os
 import urllib.parse
 
 from django.conf import settings
 from django.contrib.syndication.views import Feed
 from django.core.exceptions import PermissionDenied
-from django.http import HttpResponse, Http404
+from django.core.files.storage import default_storage
+from django.http import HttpResponse, Http404, JsonResponse
 from django.utils.feedgenerator import Atom1Feed
 from django.views import View
+from django.views.decorators.csrf import csrf_exempt
 from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView, TemplateView
 
+from sorl.thumbnail.shortcuts import get_thumbnail
+
 from .models import Note
 
 
@@ -144,3 +149,22 @@ class AtomFeed(Feed):
 
     def item_pubdate(self, item):
         return item.creation_timestamp
+
+
+@csrf_exempt
+def ajax_upload(request, *args, **kwargs):
+    upload = request.FILES['upload']
+    upload_path = 'uploads'
+    if os.path.splitext(upload.name.lower())[-1] in ('.jpg', '.jpeg', '.png', '.gif', '.svg'):
+        upload_path = 'images'
+    saved_path = default_storage.save('%s/%s' % (upload_path, upload.name), upload)
+    url = '/media/' + saved_path
+    response = {'url': url, 'filename': upload.name}
+    if upload_path == 'images':
+        if default_storage.size(saved_path) > 500_000 and not upload.name.endswith('.svg'):
+            response['orig_url'] = url
+            try:
+                response['url'] = get_thumbnail(saved_path, '1000', upscale=False).url
+            except OSError:
+                pass
+    return JsonResponse(response)
index 13ee8a1..a3f3e44 100644 (file)
@@ -41,6 +41,7 @@ INSTALLED_APPS = [
     'django.contrib.staticfiles',
     'ckeditor',
     'gadjo',
+    'sorl.thumbnail',
     'taggit',
     'chloro.phyll',
 ]