From 6d55b15604a22eca1e250dcc8a7ae03ed97cdbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Bolvin?= Date: Wed, 7 Mar 2018 18:50:43 +0100 Subject: [PATCH] Resize images on client-side before uploading --- Gemfile | 1 + Gemfile.lock | 2 + .../app/views/publisher/uploader_view.js | 83 ++++------- .../javascripts/app/views/publisher_view.js | 8 +- .../helpers/post_photo_uploader.es6 | 103 +++++++++++++ app/assets/javascripts/main.js | 1 + app/assets/javascripts/mobile/mobile.js | 2 + .../mobile/mobile_file_uploader.js | 139 ++++++++---------- config/initializers/secure_headers.rb | 8 +- .../app/views/publisher_view_spec.js | 47 +++--- 10 files changed, 232 insertions(+), 162 deletions(-) create mode 100644 app/assets/javascripts/helpers/post_photo_uploader.es6 diff --git a/Gemfile b/Gemfile index 3bc87ea25..8c15aa3ad 100644 --- a/Gemfile +++ b/Gemfile @@ -113,6 +113,7 @@ source "https://rails-assets.org" do gem "rails-assets-corejs-typeahead", "1.1.1" gem "rails-assets-cropperjs", "1.2.1" gem "rails-assets-fine-uploader", "5.13.0" + gem "rails-assets-pica", "4.0.1" # jQuery plugins diff --git a/Gemfile.lock b/Gemfile.lock index bf4cbe7a5..6fe7aa608 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -558,6 +558,7 @@ GEM rails-assets-markdown-it-sub (1.0.0) rails-assets-markdown-it-sup (1.0.0) rails-assets-perfect-scrollbar (0.6.16) + rails-assets-pica (4.0.1) rails-assets-underscore (1.8.3) rails-controller-testing (1.0.2) actionpack (~> 5.x, >= 5.0.1) @@ -878,6 +879,7 @@ DEPENDENCIES rails-assets-markdown-it-sub (= 1.0.0)! rails-assets-markdown-it-sup (= 1.0.0)! rails-assets-perfect-scrollbar (= 0.6.16)! + rails-assets-pica (= 4.0.1)! rails-controller-testing (= 1.0.2) rails-i18n (= 5.0.4) rails-timeago (= 2.16.0) diff --git a/app/assets/javascripts/app/views/publisher/uploader_view.js b/app/assets/javascripts/app/views/publisher/uploader_view.js index e03f6c9d1..e046a00ea 100644 --- a/app/assets/javascripts/app/views/publisher/uploader_view.js +++ b/app/assets/javascripts/app/views/publisher/uploader_view.js @@ -5,65 +5,19 @@ // progress. Attaches previews of finished uploads to the publisher. app.views.PublisherUploader = Backbone.View.extend({ - allowedExtensions: ["jpg", "jpeg", "png", "gif"], - sizeLimit: 4194304, // bytes - initialize: function(opts) { this.publisher = opts.publisher; - this.uploader = new qq.FineUploaderBasic({ - element: this.el, - button: this.el, - - text: { - fileInputTitle: Diaspora.I18n.t("photo_uploader.upload_photos") - }, - request: { - endpoint: Routes.photos(), - params: { - /* eslint-disable camelcase */ - authenticity_token: $("meta[name='csrf-token']").attr("content"), - /* eslint-enable camelcase */ - photo: { - pending: true - } - } - }, - validation: { - allowedExtensions: this.allowedExtensions, - sizeLimit: this.sizeLimit - }, - messages: { - typeError: Diaspora.I18n.t("photo_uploader.invalid_ext"), - sizeError: Diaspora.I18n.t("photo_uploader.size_error"), - emptyError: Diaspora.I18n.t("photo_uploader.empty") - }, - callbacks: { - onProgress: _.bind(this.progressHandler, this), - onSubmit: _.bind(this.submitHandler, this), - onComplete: _.bind(this.uploadCompleteHandler, this), - onError: function(id, name, errorReason) { - if (app.flashMessages) { app.flashMessages.error(errorReason); } - } - } - }); this.info = $("
"); this.publisher.wrapperEl.before(this.info); - this.publisher.photozoneEl.on("click", ".x", _.bind(this._removePhoto, this)); - }, - progressHandler: function(id, fileName, loaded, total) { - var progress = Math.round(loaded / total * 100); - this.info.text(fileName + " " + progress + "%").fadeTo(200, 1); - this.publisher.photozoneEl - .find("li.loading").first().find(".progress-bar") - .width(progress + "%"); - }, + // Initialize the PostPhotoUploader and subscribe its events + this.uploader = new Diaspora.PostPhotoUploader(this.el); - submitHandler: function() { - this.$el.addClass("loading"); - this._addPhotoPlaceholder(); + this.uploader.onUploadStarted = _.bind(this.uploadStartedHandler, this); + this.uploader.onProgress = _.bind(this.progressHandler, this); + this.uploader.onUploadCompleted = _.bind(this.uploadCompleteHandler, this); }, // add photo placeholders to the publisher to indicate an upload in progress @@ -82,14 +36,26 @@ app.views.PublisherUploader = Backbone.View.extend({ ); }, + uploadStartedHandler: function() { + this.$el.addClass("loading"); + this._addPhotoPlaceholder(); + }, + + progressHandler: function(fileName, progress) { + this.info.text(fileName + " " + progress + "%").fadeTo(200, 1); + this.publisher.photozoneEl + .find("li.loading").first().find(".progress-bar") + .width(progress + "%"); + }, + uploadCompleteHandler: function(_id, fileName, response) { if (response.success){ this.info.text(Diaspora.I18n.t("photo_uploader.completed", {file: fileName})).fadeTo(2000, 0); var id = response.data.photo.id, - url = response.data.photo.unprocessed_image.url; + image = response.data.photo.unprocessed_image; - this._addFinishedPhoto(id, url); + this._addFinishedPhoto(id, image); this.trigger("change"); } else { this._cancelPhotoUpload(); @@ -105,14 +71,13 @@ app.views.PublisherUploader = Backbone.View.extend({ // replace the first photo placeholder with the finished uploaded image and // add the id to the publishers form - _addFinishedPhoto: function(id, url) { + _addFinishedPhoto: function(id, image) { var publisher = this.publisher; // add form input element publisher.$(".content_creation form").append( "" ); - // replace placeholder var placeholder = publisher.photozoneEl.find("li.loading").first(); placeholder @@ -121,7 +86,13 @@ app.views.PublisherUploader = Backbone.View.extend({ "
"+ "
" ) - .find("img").attr({"src": url, "data-id": id}).removeClass("ajax-loader"); + .find("img").attr( + { + "src": image.thumb_medium.url, + "data-small": image.thumb_small.url, + "data-scaled": image.scaled_full.url, + "data-id": id + }).removeClass("ajax-loader"); placeholder .find("div.progress").remove(); diff --git a/app/assets/javascripts/app/views/publisher_view.js b/app/assets/javascripts/app/views/publisher_view.js index 84f957395..5db247145 100644 --- a/app/assets/javascripts/app/views/publisher_view.js +++ b/app/assets/javascripts/app/views/publisher_view.js @@ -284,13 +284,13 @@ app.views.Publisher = Backbone.View.extend({ getUploadedPhotos: function() { var photos = []; $("li.publisher_photo img").each(function() { - var file = $(this).attr("src").substring("/uploads/images/".length); + var photo = $(this); photos.push( { "sizes": { - "small" : "/uploads/images/thumb_small_" + file, - "medium" : "/uploads/images/thumb_medium_" + file, - "large" : "/uploads/images/scaled_full_" + file + "small": photo.data("small"), + "medium": photo.attr("src"), + "large": photo.data("scaled") } } ); diff --git a/app/assets/javascripts/helpers/post_photo_uploader.es6 b/app/assets/javascripts/helpers/post_photo_uploader.es6 new file mode 100644 index 000000000..5a786f64b --- /dev/null +++ b/app/assets/javascripts/helpers/post_photo_uploader.es6 @@ -0,0 +1,103 @@ +// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later + +Diaspora.PostPhotoUploader = class { + /** + * Initializes a new instance of PostPhotoUploader + * This class handles uploading photos and provides client side scaling + */ + constructor(el, aspectIds) { + this.element = el; + this.sizeLimit = 4194304; + this.aspectIds = aspectIds; + + this.onProgress = null; + this.onUploadStarted = null; + this.onUploadCompleted = null; + + /** + * Shows a message using flash messages or alert for mobile. + * @param {string} type - The type of the message, e.g. "error" or "success". + * @param text - The text to display. + */ + this.showMessage = (type, text) => (app.flashMessages ? app.flashMessages[type](text) : alert(text)); + + /** + * Returns true if the given parameter is a function + * @param {param} - The object to check + * @returns {boolean} + */ + this.func = param => (typeof param === "function"); + + this.initFineUploader(); + } + + /** + * Initializes the fine uploader component + */ + initFineUploader() { + this.fineUploader = new qq.FineUploaderBasic({ + element: this.element, + button: this.element, + text: { + fileInputTitle: Diaspora.I18n.t("photo_uploader.upload_photos") + }, + request: { + endpoint: Routes.photos(), + params: { + /* eslint-disable camelcase */ + authenticity_token: $("meta[name='csrf-token']").attr("content"), + photo: { + pending: true, + aspect_ids: this.aspectIds + } + /* eslint-enable camelcase */ + } + }, + validation: { + allowedExtensions: ["jpg", "jpeg", "png", "gif"], + sizeLimit: (window.Promise && qq.supportedFeatures.scaling ? null : this.sizeLimit) + }, + messages: { + typeError: Diaspora.I18n.t("photo_uploader.invalid_ext"), + sizeError: Diaspora.I18n.t("photo_uploader.size_error"), + emptyError: Diaspora.I18n.t("photo_uploader.empty") + }, + callbacks: { + onSubmit: (id, name) => this.onPictureSelected(id, name), + onUpload: (id, name) => (this.func(this.onUploadStarted) && this.onUploadStarted(id, name)), + onProgress: (id, fileName, loaded, total) => + (this.func(this.onProgress) && this.onProgress(fileName, Math.round(loaded / total * 100))), + onComplete: (id, name, json) => (this.func(this.onUploadCompleted) && this.onUploadCompleted(id, name, json)), + onError: (id, name, errorReason) => this.showMessage("error", errorReason) + } + }); + } + + /** + * Called when a picture from user's device has been selected. + * Scales the images using Pica if the image exceeds the file size limit + * @param {number} id - The current file's id. + * @param {string} name - The current file's name. + */ + onPictureSelected(id, name) { + // scale image because it's bigger than the size limit and the browser supports it + if (this.fineUploader.getSize(id) > this.sizeLimit && window.Promise && qq.supportedFeatures.scaling) { + this.fineUploader.scaleImage(id, { + maxSize: 3072, + customResizer: !qq.ios() && (i => window.pica().resize(i.sourceCanvas, i.targetCanvas)) + }).then(scaledImage => { + this.fineUploader.addFiles({ + blob: scaledImage, + name: name + }); + }); + + // since we are adding the smaller scaled image afterwards, we return false + return false; + } + + // return true to upload the image without scaling + return true; + } +}; +// @license-end diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index a47f75d68..f013fed73 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -46,3 +46,4 @@ //= require helpers/markdown_editor //= require jquery.are-you-sure //= require cropperjs/dist/cropper.js +//= require pica diff --git a/app/assets/javascripts/mobile/mobile.js b/app/assets/javascripts/mobile/mobile.js index 7d69a4a47..3d12c4281 100644 --- a/app/assets/javascripts/mobile/mobile.js +++ b/app/assets/javascripts/mobile/mobile.js @@ -14,11 +14,13 @@ //= require jquery.timeago //= require underscore //= require bootstrap +//= require pica //= require diaspora //= require helpers/i18n //= require helpers/tags_autocomplete //= require bootstrap-markdown/bootstrap-markdown //= require helpers/markdown_editor +//= require helpers/post_photo_uploader //= require widgets/timeago //= require mobile/mobile_application //= require mobile/mobile_file_uploader diff --git a/app/assets/javascripts/mobile/mobile_file_uploader.js b/app/assets/javascripts/mobile/mobile_file_uploader.js index e7750babd..af8c20732 100644 --- a/app/assets/javascripts/mobile/mobile_file_uploader.js +++ b/app/assets/javascripts/mobile/mobile_file_uploader.js @@ -2,97 +2,74 @@ //= require js_image_paths function createUploader(){ - var aspectIds = gon.preloads.aspect_ids; + var aspectIds = gon.preloads.aspect_ids; + var fileInfo = $("#fileInfo-publisher"); - new qq.FineUploaderBasic({ - element: document.getElementById("file-upload-publisher"), - request: { - endpoint: Routes.photos(), - params: { - /* eslint-disable camelcase */ - authenticity_token: $("meta[name='csrf-token']").attr("content"), - photo: { - aspect_ids: aspectIds, - /* eslint-enable camelcase */ - pending: true - } - } - }, - validation: { - allowedExtensions: ["jpg", "jpeg", "png", "gif"], - sizeLimit: 4194304 - }, - button: document.getElementById("file-upload-publisher"), - text: { - fileInputTitle: Diaspora.I18n.t("photo_uploader.upload_photos") - }, + // Initialize the PostPhotoUploader and subscribe its events + this.uploader = new Diaspora.PostPhotoUploader(document.getElementById("file-upload-publisher"), aspectIds); - callbacks: { - onProgress: function(id, fileName, loaded, total) { - var progress = Math.round(loaded / total * 100); - $("#fileInfo-publisher").text(fileName + " " + progress + "%"); - }, - onSubmit: function() { - $("#publisher-textarea-wrapper").addClass("with_attachments"); - $("#photodropzone").append( - "
  • " + - "Ajax-loader2" + - "
  • " - ); - }, - onComplete: function(_id, fileName, responseJSON) { - if (responseJSON.data === undefined) { - return; - } + this.uploader.onUploadStarted = _.bind(uploadStartedHandler, this); + this.uploader.onProgress = _.bind(progressHandler, this); + this.uploader.onUploadCompleted = _.bind(uploadCompletedHandler, this); - $("#fileInfo-publisher").text(Diaspora.I18n.t("photo_uploader.completed", {"file": fileName})); - var id = responseJSON.data.photo.id, - url = responseJSON.data.photo.unprocessed_image.url, - currentPlaceholder = $("li.loading").first(); + function progressHandler(fileName, progress) { + fileInfo.text(fileName + " " + progress + "%"); + } - $("#publisher-textarea-wrapper").addClass("with_attachments"); - $("#new_status_message").append(""); + function uploadStartedHandler() { + $("#publisher-textarea-wrapper").addClass("with_attachments"); + $("#photodropzone").append( + "
  • " + + "Ajax-loader2" + + "
  • " + ); + } - // replace image placeholders - var img = currentPlaceholder.find("img"); - img.attr("src", url); - img.attr("data-id", id); - currentPlaceholder.removeClass("loading"); - currentPlaceholder.append("
    X
    " + - "
    "); + function uploadCompletedHandler(_id, fileName, responseJSON) { + if (responseJSON.data === undefined) { + return; + } - var publisher = $("#publisher"); + fileInfo.text(Diaspora.I18n.t("photo_uploader.completed", {"file": fileName})); + var id = responseJSON.data.photo.id, + image = responseJSON.data.photo.unprocessed_image, + currentPlaceholder = $("li.loading").first(); - publisher.find("input[type='submit']").removeAttr("disabled"); + $("#publisher-textarea-wrapper").addClass("with_attachments"); + $("#new_status_message").append(""); - $(".x").bind("click", function() { - var photo = $(this).closest(".publisher_photo"); - photo.addClass("dim"); - $.ajax({ - url: "/photos/" + photo.children("img").attr("data-id"), - dataType: "json", - type: "DELETE", - success: function() { - photo.fadeOut(400, function() { - photo.remove(); - if ($(".publisher_photo").length === 0) { - $("#publisher-textarea-wrapper").removeClass("with_attachments"); - } - }); + // replace image placeholders + var img = currentPlaceholder.find("img"); + img.attr("src", image.thumb_medium.url); + img.attr("data-small", image.thumb_small.url); + img.attr("data-scaled", image.scaled_full.url); + img.attr("data-id", id); + currentPlaceholder.removeClass("loading"); + currentPlaceholder.append("
    X
    " + + "
    "); + + var publisher = $("#publisher"); + + publisher.find("input[type='submit']").removeAttr("disabled"); + + $(".x").bind("click", function() { + var photo = $(this).closest(".publisher_photo"); + photo.addClass("dim"); + $.ajax({ + url: "/photos/" + photo.children("img").attr("data-id"), + dataType: "json", + type: "DELETE", + success: function() { + photo.fadeOut(400, function() { + photo.remove(); + if ($(".publisher_photo").length === 0) { + $("#publisher-textarea-wrapper").removeClass("with_attachments"); } }); - }); - }, - onError: function(id, name, errorReason) { - alert(errorReason); - } - }, - messages: { - typeError: Diaspora.I18n.t("photo_uploader.invalid_ext"), - sizeError: Diaspora.I18n.t("photo_uploader.size_error"), - emptyError: Diaspora.I18n.t("photo_uploader.empty") - } - }); + } + }); + }); + } } window.addEventListener("load", function() { createUploader(); diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb index a49d5378c..1ed7a0641 100644 --- a/config/initializers/secure_headers.rb +++ b/config/initializers/secure_headers.rb @@ -6,16 +6,16 @@ SecureHeaders::Configuration.default do |config| # rubocop:disable Lint/PercentStringArray csp = { default_src: %w['none'], - child_src: %w['self' www.youtube.com w.soundcloud.com twitter.com platform.twitter.com syndication.twitter.com - player.vimeo.com www.mixcloud.com www.dailymotion.com media.ccc.de bandcamp.com - www.instagram.com], + child_src: %w['self' blob: www.youtube.com w.soundcloud.com twitter.com platform.twitter.com + syndication.twitter.com player.vimeo.com www.mixcloud.com www.dailymotion.com media.ccc.de + bandcamp.com www.instagram.com], connect_src: %w['self' embedr.flickr.com geo.query.yahoo.com nominatim.openstreetmap.org api.github.com], font_src: %w['self'], form_action: %w['self' platform.twitter.com syndication.twitter.com], frame_ancestors: %w['self'], img_src: %w['self' data: blob: *], media_src: %w[https:], - script_src: %w['self' 'unsafe-eval' platform.twitter.com cdn.syndication.twimg.com widgets.flickr.com + script_src: %w['self' blob: 'unsafe-eval' platform.twitter.com cdn.syndication.twimg.com widgets.flickr.com embedr.flickr.com platform.instagram.com 'unsafe-inline'], style_src: %w['self' 'unsafe-inline' platform.twitter.com *.twimg.com] } diff --git a/spec/javascripts/app/views/publisher_view_spec.js b/spec/javascripts/app/views/publisher_view_spec.js index a43d12a76..33f3edcb0 100644 --- a/spec/javascripts/app/views/publisher_view_spec.js +++ b/spec/javascripts/app/views/publisher_view_spec.js @@ -539,36 +539,36 @@ describe("app.views.Publisher", function() { this.view = new app.views.Publisher(); // replace the uploader plugin with a dummy object - var upload_view = this.view.viewUploader; + var uploadView = this.view.viewUploader; this.uploader = { - onProgress: _.bind(upload_view.progressHandler, upload_view), - onSubmit: _.bind(upload_view.submitHandler, upload_view), - onComplete: _.bind(upload_view.uploadCompleteHandler, upload_view) + onProgress: _.bind(uploadView.progressHandler, uploadView), + onUpload: _.bind(uploadView.uploadStartedHandler, uploadView), + onComplete: _.bind(uploadView.uploadCompleteHandler, uploadView) }; - upload_view.uploader = this.uploader; + uploadView.uploader = this.uploader; }); - context('progress', function() { - it('shows progress in percent', function() { - this.uploader.onProgress(null, 'test.jpg', 20, 100); + context("progress", function() { + it("shows progress in percent", function() { + this.uploader.onProgress("test.jpg", 20); var info = this.view.viewUploader.info; - expect(info.text()).toContain('test.jpg'); - expect(info.text()).toContain('20%'); + expect(info.text()).toContain("test.jpg"); + expect(info.text()).toContain("20%"); }); }); - context('submitting', function() { + context("submitting", function() { beforeEach(function() { - this.uploader.onSubmit(null, 'test.jpg'); + this.uploader.onUpload(null, "test.jpg"); }); - it('adds a placeholder', function() { + it("adds a placeholder", function() { expect(this.view.wrapperEl.attr("class")).toContain("with_attachments"); expect(this.view.photozoneEl.find("li").length).toBe(1); }); - it('disables the publisher buttons', function() { + it("disables the publisher buttons", function() { expect(this.view.submitEl.prop("disabled")).toBeTruthy(); }); }); @@ -577,13 +577,20 @@ describe("app.views.Publisher", function() { beforeEach(function() { $('#photodropzone').html('
  • '); + /* eslint-disable camelcase */ this.uploader.onComplete(null, 'test.jpg', { data: { photo: { id: '987', - unprocessed_image: { url: 'test.jpg' } + unprocessed_image: { + thumb_small: {url: "test.jpg"}, + thumb_medium: {url: "test.jpg"}, + thumb_large: {url: "test.jpg"}, + scaled_full: {url: "test.jpg"} + } }}, success: true }); }); + /* eslint-enable camelcase */ it('shows it in text form', function() { var info = this.view.viewUploader.info; @@ -613,14 +620,20 @@ describe("app.views.Publisher", function() { beforeEach(function() { $('#photodropzone').html('
  • '); + /* eslint-disable camelcase */ this.uploader.onComplete(null, 'test.jpg', { data: { photo: { id: '987', - unprocessed_image: { url: 'test.jpg' } + unprocessed_image: { + thumb_small: {url: "test.jpg"}, + thumb_medium: {url: "test.jpg"}, + thumb_large: {url: "test.jpg"}, + scaled_full: {url: "test.jpg"} + } }}, success: false }); }); - + /* eslint-enable camelcase */ it('shows error message', function() { var info = this.view.viewUploader.info; expect(info.text()).toBe(Diaspora.I18n.t('photo_uploader.error', {file: 'test.jpg'}));