Merge pull request #7734 from Fensterbank/client-side-picture-resize

Resize images on client-side before uploading
This commit is contained in:
Dennis Schubert 2018-04-02 10:37:45 +02:00
commit ae17248b2e
No known key found for this signature in database
GPG key ID: 5A0304BEA7966D7E
11 changed files with 234 additions and 163 deletions

View file

@ -1,12 +1,13 @@
# 0.8.0.0 # 0.8.0.0
## Refactor ## Refactor
* Add bootstrapping for using ECMAScript 6 with automatic transpiling for compatiblilty [#7581](https://github.com/diaspora/diaspora/pull/7581) * Add bootstrapping for using ECMAScript 6 with automatic transpiling for compatibility [#7581](https://github.com/diaspora/diaspora/pull/7581)
## Bug fixes ## Bug fixes
## Features ## Features
* Add client-side cropping of profile image uploads [#7581](https://github.com/diaspora/diaspora/pull/7581) * Add client-side cropping of profile image uploads [#7581](https://github.com/diaspora/diaspora/pull/7581)
* Add client-site rescaling of post images if they exceed the maximum possible size [#7734](https://github.com/diaspora/diaspora/pull/7734)
# 0.7.5.0 # 0.7.5.0

View file

@ -113,6 +113,7 @@ source "https://rails-assets.org" do
gem "rails-assets-corejs-typeahead", "1.1.1" gem "rails-assets-corejs-typeahead", "1.1.1"
gem "rails-assets-cropperjs", "1.2.1" gem "rails-assets-cropperjs", "1.2.1"
gem "rails-assets-fine-uploader", "5.13.0" gem "rails-assets-fine-uploader", "5.13.0"
gem "rails-assets-pica", "4.0.1"
# jQuery plugins # jQuery plugins

View file

@ -558,6 +558,7 @@ GEM
rails-assets-markdown-it-sub (1.0.0) rails-assets-markdown-it-sub (1.0.0)
rails-assets-markdown-it-sup (1.0.0) rails-assets-markdown-it-sup (1.0.0)
rails-assets-perfect-scrollbar (0.6.16) rails-assets-perfect-scrollbar (0.6.16)
rails-assets-pica (4.0.1)
rails-assets-underscore (1.8.3) rails-assets-underscore (1.8.3)
rails-controller-testing (1.0.2) rails-controller-testing (1.0.2)
actionpack (~> 5.x, >= 5.0.1) actionpack (~> 5.x, >= 5.0.1)
@ -878,6 +879,7 @@ DEPENDENCIES
rails-assets-markdown-it-sub (= 1.0.0)! rails-assets-markdown-it-sub (= 1.0.0)!
rails-assets-markdown-it-sup (= 1.0.0)! rails-assets-markdown-it-sup (= 1.0.0)!
rails-assets-perfect-scrollbar (= 0.6.16)! rails-assets-perfect-scrollbar (= 0.6.16)!
rails-assets-pica (= 4.0.1)!
rails-controller-testing (= 1.0.2) rails-controller-testing (= 1.0.2)
rails-i18n (= 5.1.1) rails-i18n (= 5.1.1)
rails-timeago (= 2.16.0) rails-timeago (= 2.16.0)

View file

@ -5,65 +5,19 @@
// progress. Attaches previews of finished uploads to the publisher. // progress. Attaches previews of finished uploads to the publisher.
app.views.PublisherUploader = Backbone.View.extend({ app.views.PublisherUploader = Backbone.View.extend({
allowedExtensions: ["jpg", "jpeg", "png", "gif"],
sizeLimit: 4194304, // bytes
initialize: function(opts) { initialize: function(opts) {
this.publisher = opts.publisher; 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 = $("<div id=\"fileInfo\" />"); this.info = $("<div id=\"fileInfo\" />");
this.publisher.wrapperEl.before(this.info); this.publisher.wrapperEl.before(this.info);
this.publisher.photozoneEl.on("click", ".x", _.bind(this._removePhoto, this)); this.publisher.photozoneEl.on("click", ".x", _.bind(this._removePhoto, this));
},
progressHandler: function(id, fileName, loaded, total) { // Initialize the PostPhotoUploader and subscribe its events
var progress = Math.round(loaded / total * 100); this.uploader = new Diaspora.PostPhotoUploader(this.el);
this.info.text(fileName + " " + progress + "%").fadeTo(200, 1);
this.publisher.photozoneEl
.find("li.loading").first().find(".progress-bar")
.width(progress + "%");
},
submitHandler: function() { this.uploader.onUploadStarted = _.bind(this.uploadStartedHandler, this);
this.$el.addClass("loading"); this.uploader.onProgress = _.bind(this.progressHandler, this);
this._addPhotoPlaceholder(); this.uploader.onUploadCompleted = _.bind(this.uploadCompleteHandler, this);
}, },
// add photo placeholders to the publisher to indicate an upload in progress // 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) { uploadCompleteHandler: function(_id, fileName, response) {
if (response.success){ if (response.success){
this.info.text(Diaspora.I18n.t("photo_uploader.completed", {file: fileName})).fadeTo(2000, 0); this.info.text(Diaspora.I18n.t("photo_uploader.completed", {file: fileName})).fadeTo(2000, 0);
var id = response.data.photo.id, 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"); this.trigger("change");
} else { } else {
this._cancelPhotoUpload(); this._cancelPhotoUpload();
@ -105,14 +71,13 @@ app.views.PublisherUploader = Backbone.View.extend({
// replace the first photo placeholder with the finished uploaded image and // replace the first photo placeholder with the finished uploaded image and
// add the id to the publishers form // add the id to the publishers form
_addFinishedPhoto: function(id, url) { _addFinishedPhoto: function(id, image) {
var publisher = this.publisher; var publisher = this.publisher;
// add form input element // add form input element
publisher.$(".content_creation form").append( publisher.$(".content_creation form").append(
"<input type=\"hidden\", value=\""+id+"\" name=\"photos[]\" />" "<input type=\"hidden\", value=\""+id+"\" name=\"photos[]\" />"
); );
// replace placeholder // replace placeholder
var placeholder = publisher.photozoneEl.find("li.loading").first(); var placeholder = publisher.photozoneEl.find("li.loading").first();
placeholder placeholder
@ -121,7 +86,13 @@ app.views.PublisherUploader = Backbone.View.extend({
"<div class=\"x\"></div>"+ "<div class=\"x\"></div>"+
"<div class=\"circle\"></div>" "<div class=\"circle\"></div>"
) )
.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 placeholder
.find("div.progress").remove(); .find("div.progress").remove();

View file

@ -284,13 +284,13 @@ app.views.Publisher = Backbone.View.extend({
getUploadedPhotos: function() { getUploadedPhotos: function() {
var photos = []; var photos = [];
$("li.publisher_photo img").each(function() { $("li.publisher_photo img").each(function() {
var file = $(this).attr("src").substring("/uploads/images/".length); var photo = $(this);
photos.push( photos.push(
{ {
"sizes": { "sizes": {
"small" : "/uploads/images/thumb_small_" + file, "small": photo.data("small"),
"medium" : "/uploads/images/thumb_medium_" + file, "medium": photo.attr("src"),
"large" : "/uploads/images/scaled_full_" + file "large": photo.data("scaled")
} }
} }
); );

View file

@ -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

View file

@ -47,3 +47,4 @@
//= require helpers/markdown_editor //= require helpers/markdown_editor
//= require jquery.are-you-sure //= require jquery.are-you-sure
//= require cropperjs/dist/cropper.js //= require cropperjs/dist/cropper.js
//= require pica

View file

@ -14,11 +14,13 @@
//= require jquery.timeago //= require jquery.timeago
//= require underscore //= require underscore
//= require bootstrap //= require bootstrap
//= require pica
//= require diaspora //= require diaspora
//= require helpers/i18n //= require helpers/i18n
//= require helpers/tags_autocomplete //= require helpers/tags_autocomplete
//= require bootstrap-markdown/bootstrap-markdown //= require bootstrap-markdown/bootstrap-markdown
//= require helpers/markdown_editor //= require helpers/markdown_editor
//= require helpers/post_photo_uploader
//= require widgets/timeago //= require widgets/timeago
//= require mobile/mobile_application //= require mobile/mobile_application
//= require mobile/mobile_file_uploader //= require mobile/mobile_file_uploader

View file

@ -3,51 +3,36 @@
function createUploader(){ function createUploader(){
var aspectIds = gon.preloads.aspect_ids; var aspectIds = gon.preloads.aspect_ids;
var fileInfo = $("#fileInfo-publisher");
new qq.FineUploaderBasic({ // Initialize the PostPhotoUploader and subscribe its events
element: document.getElementById("file-upload-publisher"), this.uploader = new Diaspora.PostPhotoUploader(document.getElementById("file-upload-publisher"), aspectIds);
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")
},
callbacks: { this.uploader.onUploadStarted = _.bind(uploadStartedHandler, this);
onProgress: function(id, fileName, loaded, total) { this.uploader.onProgress = _.bind(progressHandler, this);
var progress = Math.round(loaded / total * 100); this.uploader.onUploadCompleted = _.bind(uploadCompletedHandler, this);
$("#fileInfo-publisher").text(fileName + " " + progress + "%");
}, function progressHandler(fileName, progress) {
onSubmit: function() { fileInfo.text(fileName + " " + progress + "%");
}
function uploadStartedHandler() {
$("#publisher-textarea-wrapper").addClass("with_attachments"); $("#publisher-textarea-wrapper").addClass("with_attachments");
$("#photodropzone").append( $("#photodropzone").append(
"<li class='publisher_photo loading' style='position:relative;'>" + "<li class='publisher_photo loading' style='position:relative;'>" +
"<img alt='Ajax-loader2' src='" + ImagePaths.get("ajax-loader2.gif") + "' />" + "<img alt='Ajax-loader2' src='" + ImagePaths.get("ajax-loader2.gif") + "' />" +
"</li>" "</li>"
); );
}, }
onComplete: function(_id, fileName, responseJSON) {
function uploadCompletedHandler(_id, fileName, responseJSON) {
if (responseJSON.data === undefined) { if (responseJSON.data === undefined) {
return; return;
} }
$("#fileInfo-publisher").text(Diaspora.I18n.t("photo_uploader.completed", {"file": fileName})); fileInfo.text(Diaspora.I18n.t("photo_uploader.completed", {"file": fileName}));
var id = responseJSON.data.photo.id, var id = responseJSON.data.photo.id,
url = responseJSON.data.photo.unprocessed_image.url, image = responseJSON.data.photo.unprocessed_image,
currentPlaceholder = $("li.loading").first(); currentPlaceholder = $("li.loading").first();
$("#publisher-textarea-wrapper").addClass("with_attachments"); $("#publisher-textarea-wrapper").addClass("with_attachments");
@ -55,7 +40,9 @@ function createUploader(){
// replace image placeholders // replace image placeholders
var img = currentPlaceholder.find("img"); var img = currentPlaceholder.find("img");
img.attr("src", url); 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); img.attr("data-id", id);
currentPlaceholder.removeClass("loading"); currentPlaceholder.removeClass("loading");
currentPlaceholder.append("<div class='x'>X</div>" + currentPlaceholder.append("<div class='x'>X</div>" +
@ -82,17 +69,7 @@ function createUploader(){
} }
}); });
}); });
},
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() { window.addEventListener("load", function() {
createUploader(); createUploader();

View file

@ -6,16 +6,16 @@ SecureHeaders::Configuration.default do |config|
# rubocop:disable Lint/PercentStringArray # rubocop:disable Lint/PercentStringArray
csp = { csp = {
default_src: %w['none'], default_src: %w['none'],
child_src: %w['self' www.youtube.com w.soundcloud.com twitter.com platform.twitter.com syndication.twitter.com child_src: %w['self' blob: www.youtube.com w.soundcloud.com twitter.com platform.twitter.com
player.vimeo.com www.mixcloud.com www.dailymotion.com media.ccc.de bandcamp.com syndication.twitter.com player.vimeo.com www.mixcloud.com www.dailymotion.com media.ccc.de
www.instagram.com], bandcamp.com www.instagram.com],
connect_src: %w['self' embedr.flickr.com geo.query.yahoo.com nominatim.openstreetmap.org api.github.com], connect_src: %w['self' embedr.flickr.com geo.query.yahoo.com nominatim.openstreetmap.org api.github.com],
font_src: %w['self'], font_src: %w['self'],
form_action: %w['self' platform.twitter.com syndication.twitter.com], form_action: %w['self' platform.twitter.com syndication.twitter.com],
frame_ancestors: %w['self'], frame_ancestors: %w['self'],
img_src: %w['self' data: blob: *], img_src: %w['self' data: blob: *],
media_src: %w[https:], 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'], embedr.flickr.com platform.instagram.com 'unsafe-inline'],
style_src: %w['self' 'unsafe-inline' platform.twitter.com *.twimg.com] style_src: %w['self' 'unsafe-inline' platform.twitter.com *.twimg.com]
} }

View file

@ -539,36 +539,36 @@ describe("app.views.Publisher", function() {
this.view = new app.views.Publisher(); this.view = new app.views.Publisher();
// replace the uploader plugin with a dummy object // replace the uploader plugin with a dummy object
var upload_view = this.view.viewUploader; var uploadView = this.view.viewUploader;
this.uploader = { this.uploader = {
onProgress: _.bind(upload_view.progressHandler, upload_view), onProgress: _.bind(uploadView.progressHandler, uploadView),
onSubmit: _.bind(upload_view.submitHandler, upload_view), onUpload: _.bind(uploadView.uploadStartedHandler, uploadView),
onComplete: _.bind(upload_view.uploadCompleteHandler, upload_view) onComplete: _.bind(uploadView.uploadCompleteHandler, uploadView)
}; };
upload_view.uploader = this.uploader; uploadView.uploader = this.uploader;
}); });
context('progress', function() { context("progress", function() {
it('shows progress in percent', function() { it("shows progress in percent", function() {
this.uploader.onProgress(null, 'test.jpg', 20, 100); this.uploader.onProgress("test.jpg", 20);
var info = this.view.viewUploader.info; var info = this.view.viewUploader.info;
expect(info.text()).toContain('test.jpg'); expect(info.text()).toContain("test.jpg");
expect(info.text()).toContain('20%'); expect(info.text()).toContain("20%");
}); });
}); });
context('submitting', function() { context("submitting", function() {
beforeEach(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.wrapperEl.attr("class")).toContain("with_attachments");
expect(this.view.photozoneEl.find("li").length).toBe(1); 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(); expect(this.view.submitEl.prop("disabled")).toBeTruthy();
}); });
}); });
@ -577,13 +577,20 @@ describe("app.views.Publisher", function() {
beforeEach(function() { beforeEach(function() {
$('#photodropzone').html('<li class="publisher_photo loading"><img src="" /></li>'); $('#photodropzone').html('<li class="publisher_photo loading"><img src="" /></li>');
/* eslint-disable camelcase */
this.uploader.onComplete(null, 'test.jpg', { this.uploader.onComplete(null, 'test.jpg', {
data: { photo: { data: { photo: {
id: '987', 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 }); success: true });
}); });
/* eslint-enable camelcase */
it('shows it in text form', function() { it('shows it in text form', function() {
var info = this.view.viewUploader.info; var info = this.view.viewUploader.info;
@ -613,14 +620,20 @@ describe("app.views.Publisher", function() {
beforeEach(function() { beforeEach(function() {
$('#photodropzone').html('<li class="publisher_photo loading"><img src="" /></li>'); $('#photodropzone').html('<li class="publisher_photo loading"><img src="" /></li>');
/* eslint-disable camelcase */
this.uploader.onComplete(null, 'test.jpg', { this.uploader.onComplete(null, 'test.jpg', {
data: { photo: { data: { photo: {
id: '987', 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 }); success: false });
}); });
/* eslint-enable camelcase */
it('shows error message', function() { it('shows error message', function() {
var info = this.view.viewUploader.info; var info = this.view.viewUploader.info;
expect(info.text()).toBe(Diaspora.I18n.t('photo_uploader.error', {file: 'test.jpg'})); expect(info.text()).toBe(Diaspora.I18n.t('photo_uploader.error', {file: 'test.jpg'}));