Commit 6d580880 authored by Johann Pardanaud's avatar Johann Pardanaud

Fix #7959: Fix avatar stretching by providing a cropping feature

parent 16abacb1
...@@ -77,6 +77,9 @@ gem "haml-rails", '~> 0.9.0' ...@@ -77,6 +77,9 @@ gem "haml-rails", '~> 0.9.0'
# Files attachments # Files attachments
gem "carrierwave", '~> 0.9.0' gem "carrierwave", '~> 0.9.0'
# Image editing
gem "mini_magick", '~> 4.4.0'
# Drag and Drop UI # Drag and Drop UI
gem 'dropzonejs-rails', '~> 0.7.1' gem 'dropzonejs-rails', '~> 0.7.1'
......
...@@ -468,6 +468,7 @@ GEM ...@@ -468,6 +468,7 @@ GEM
method_source (0.8.2) method_source (0.8.2)
mime-types (1.25.1) mime-types (1.25.1)
mimemagic (0.3.0) mimemagic (0.3.0)
mini_magick (4.4.0)
mini_portile2 (2.0.0) mini_portile2 (2.0.0)
minitest (5.7.0) minitest (5.7.0)
mousetrap-rails (1.4.6) mousetrap-rails (1.4.6)
...@@ -954,6 +955,7 @@ DEPENDENCIES ...@@ -954,6 +955,7 @@ DEPENDENCIES
loofah (~> 2.0.3) loofah (~> 2.0.3)
mail_room (~> 0.6.1) mail_room (~> 0.6.1)
method_source (~> 0.8) method_source (~> 0.8)
mini_magick (~> 4.4.0)
minitest (~> 5.7.0) minitest (~> 5.7.0)
mousetrap-rails (~> 1.4.6) mousetrap-rails (~> 1.4.6)
mysql2 (~> 0.3.16) mysql2 (~> 0.3.16)
......
...@@ -44,6 +44,7 @@ ...@@ -44,6 +44,7 @@
#= require jquery.nicescroll #= require jquery.nicescroll
#= require_tree . #= require_tree .
#= require fuzzaldrin-plus #= require fuzzaldrin-plus
#= require cropper.js
window.slugify = (text) -> window.slugify = (text) ->
text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase()
...@@ -210,7 +211,7 @@ $ -> ...@@ -210,7 +211,7 @@ $ ->
$this = $(this) $this = $(this)
$this.attr 'value', $this.val() $this.attr 'value', $this.val()
return return
$(document) $(document)
.off 'keyup', 'input[type="search"]' .off 'keyup', 'input[type="search"]'
.on 'keyup', 'input[type="search"]' , (e) -> .on 'keyup', 'input[type="search"]' , (e) ->
...@@ -253,7 +254,7 @@ $ -> ...@@ -253,7 +254,7 @@ $ ->
$('.page-with-sidebar') $('.page-with-sidebar')
.removeClass('right-sidebar-collapsed') .removeClass('right-sidebar-collapsed')
.addClass('right-sidebar-expanded') .addClass('right-sidebar-expanded')
$.cookie("collapsed_gutter", $.cookie("collapsed_gutter",
$('.right-sidebar') $('.right-sidebar')
.hasClass('right-sidebar-collapsed'), { path: '/' }) .hasClass('right-sidebar-collapsed'), { path: '/' })
......
...@@ -16,11 +16,51 @@ class @Profile ...@@ -16,11 +16,51 @@ class @Profile
$('.update-notifications').on 'ajax:complete', -> $('.update-notifications').on 'ajax:complete', ->
$(this).find('.btn-save').enable() $(this).find('.btn-save').enable()
$('.js-choose-user-avatar-button').bind "click", -> # Avatar management
form = $(this).closest("form")
form.find(".js-user-avatar-input").click() $avatarInput = $('.js-user-avatar-input')
$filename = $('.js-avatar-filename')
$modalCrop = $('.modal-profile-crop')
$modalCropImg = $('.modal-profile-crop-image')
$('.js-choose-user-avatar-button').on "click", ->
$form = $(this).closest("form")
$form.find(".js-user-avatar-input").click()
$modalCrop.on 'shown.bs.modal', ->
setTimeout ( -> # The cropper must be asynchronously initialized
$modalCropImg.cropper
aspectRatio: 1
autoCropArea: 1
modal: false
scalable: false
rotatable: false
zoomable: false
crop: (event) ->
['x', 'y'].forEach (key) ->
$("#user_avatar_crop_#{key}").val(Math.floor(event[key]))
$("#user_avatar_crop_size").val(Math.floor(event.width))
), 0
$modalCrop.on 'hidden.bs.modal', ->
$modalCropImg.attr('src', '').cropper('destroy')
$avatarInput.val('')
$filename.text($filename.data('label'))
$('.js-user-avatar-input').bind "change", -> $('.js-upload-user-avatar').on 'click', ->
$('.edit_user').submit()
$avatarInput.on "change", ->
form = $(this).closest("form") form = $(this).closest("form")
filename = $(this).val().replace(/^.*[\\\/]/, '') filename = $(this).val().replace(/^.*[\\\/]/, '')
form.find(".js-avatar-filename").text(filename) $filename.text(filename)
reader = new FileReader
reader.onload = (event) ->
$modalCrop.modal('show')
$modalCropImg.attr('src', event.target.result)
fileData = reader.readAsDataURL(this.files[0])
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
*= require_self *= require_self
*= require dropzone/basic *= require dropzone/basic
*= require cal-heatmap *= require cal-heatmap
*= require cropper.css
*/ */
/* /*
......
...@@ -41,6 +41,12 @@ ...@@ -41,6 +41,12 @@
transition: $transition; transition: $transition;
} }
@mixin transform($transform) {
-webkit-transform: $transform;
-ms-transform: $transform;
transform: $transform;
}
/** /**
* Prefilled mixins * Prefilled mixins
* Mixins with fixed values * Mixins with fixed values
......
...@@ -69,3 +69,39 @@ ...@@ -69,3 +69,39 @@
text-decoration: none; text-decoration: none;
} }
} }
.modal-profile-crop {
.modal-dialog {
width: 500px;
}
.modal-body {
p {
display: table;
margin: auto;
overflow: hidden;
}
img {
display: block;
max-width: 400px;
max-height: 400px;
}
.cropper-bg {
background: none;
}
.cropper-crop-box {
box-sizing: content-box;
border: 999px solid transparentize(#ccc, 0.5);
@include transform(translate(-999px, -999px));
}
}
}
@media (max-width: 520px) {
.modal-profile-crop .modal-dialog {
width: auto;
}
}
...@@ -65,6 +65,9 @@ class ProfilesController < Profiles::ApplicationController ...@@ -65,6 +65,9 @@ class ProfilesController < Profiles::ApplicationController
def user_params def user_params
params.require(:user).permit( params.require(:user).permit(
:avatar_crop_x,
:avatar_crop_y,
:avatar_crop_size,
:avatar, :avatar,
:bio, :bio,
:email, :email,
......
...@@ -98,6 +98,11 @@ class User < ActiveRecord::Base ...@@ -98,6 +98,11 @@ class User < ActiveRecord::Base
# Virtual attribute for authenticating by either username or email # Virtual attribute for authenticating by either username or email
attr_accessor :login attr_accessor :login
# Virtual attributes to define avatar cropping
[:avatar_crop_x, :avatar_crop_y, :avatar_crop_size].each do |field|
attr_accessor field
end
# #
# Relations # Relations
# #
...@@ -146,6 +151,11 @@ class User < ActiveRecord::Base ...@@ -146,6 +151,11 @@ class User < ActiveRecord::Base
# Validations # Validations
# #
validates :name, presence: true validates :name, presence: true
[:avatar_crop_x, :avatar_crop_y, :avatar_crop_size].each do |field|
validates field, numericality: { only_integer: true }, allow_blank: true
end
# Note that a 'uniqueness' and presence check is provided by devise :validatable for email. We do not need to # Note that a 'uniqueness' and presence check is provided by devise :validatable for email. We do not need to
# duplicate that here as the validation framework will have duplicate errors in the event of a failure. # duplicate that here as the validation framework will have duplicate errors in the event of a failure.
validates :email, presence: true, email: { strict_mode: true } validates :email, presence: true, email: { strict_mode: true }
......
...@@ -2,11 +2,20 @@ ...@@ -2,11 +2,20 @@
class AvatarUploader < CarrierWave::Uploader::Base class AvatarUploader < CarrierWave::Uploader::Base
include UploaderHelper include UploaderHelper
include CarrierWave::MiniMagick
storage :file storage :file
after :store, :reset_events_cache after :store, :reset_events_cache
process :cropper
def cropper
manipulate! do |img|
img.crop "#{model.avatar_crop_size}x#{model.avatar_crop_size}+#{model.avatar_crop_x}+#{model.avatar_crop_y}"
end
end
def store_dir def store_dir
"uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end end
......
...@@ -88,8 +88,11 @@ ...@@ -88,8 +88,11 @@
%i.fa.fa-paperclip %i.fa.fa-paperclip
%span Choose File ... %span Choose File ...
&nbsp; &nbsp;
%span.file_name.js-avatar-filename File name... %span.file_name.js-avatar-filename{:'data-label' => 'File name...'} File name...
= f.file_field :avatar, class: "js-user-avatar-input hidden" = f.file_field :avatar, class: "js-user-avatar-input hidden"
= f.hidden_field :avatar_crop_x
= f.hidden_field :avatar_crop_y
= f.hidden_field :avatar_crop_size
.light The maximum file size allowed is 200KB. .light The maximum file size allowed is 200KB.
- if @user.avatar? - if @user.avatar?
%hr %hr
...@@ -99,3 +102,19 @@ ...@@ -99,3 +102,19 @@
.form-actions .form-actions
= f.submit 'Save changes', class: "btn btn-success" = f.submit 'Save changes', class: "btn btn-success"
= link_to "Cancel", user_path(current_user), class: "btn btn-cancel" = link_to "Cancel", user_path(current_user), class: "btn btn-cancel"
.modal.modal-profile-crop
.modal-dialog
.modal-content
.modal-header
%button.close{:type => "button", :'data-dismiss' => "modal"}
%span
&times;
%h4.modal-title
Crop your new profile picture
.modal-body
%p
%img.modal-profile-crop-image
.modal-footer
%button.btn.btn-primary.js-upload-user-avatar{:type => "button"}
Set new profile picture
This diff is collapsed.
/*!
* Cropper v2.2.5
* https://github.com/fengyuanchen/cropper
*
* Copyright (c) 2014-2016 Fengyuan Chen and contributors
* Released under the MIT license
*
* Date: 2016-01-18T05:42:29.639Z
*/
.cropper-container {
font-size: 0;
line-height: 0;
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
direction: ltr !important;
-ms-touch-action: none;
touch-action: none;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
}
.cropper-container img {
display: block;
width: 100%;
min-width: 0 !important;
max-width: none !important;
height: 100%;
min-height: 0 !important;
max-height: none !important;
image-orientation: 0deg !important;
}
.cropper-wrap-box,
.cropper-canvas,
.cropper-drag-box,
.cropper-crop-box,
.cropper-modal {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.cropper-wrap-box {
overflow: hidden;
}
.cropper-drag-box {
opacity: 0;
background-color: #fff;
filter: alpha(opacity=0);
}
.cropper-modal {
opacity: .5;
background-color: #000;
filter: alpha(opacity=50);
}
.cropper-view-box {
display: block;
overflow: hidden;
width: 100%;
height: 100%;
outline: 1px solid #39f;
outline-color: rgba(51, 153, 255, .75);
}
.cropper-dashed {
position: absolute;
display: block;
opacity: .5;
border: 0 dashed #eee;
filter: alpha(opacity=50);
}
.cropper-dashed.dashed-h {
top: 33.33333%;
left: 0;
width: 100%;
height: 33.33333%;
border-top-width: 1px;
border-bottom-width: 1px;
}
.cropper-dashed.dashed-v {
top: 0;
left: 33.33333%;
width: 33.33333%;
height: 100%;
border-right-width: 1px;
border-left-width: 1px;
}
.cropper-center {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 0;
height: 0;
opacity: .75;
filter: alpha(opacity=75);
}
.cropper-center:before,
.cropper-center:after {
position: absolute;
display: block;
content: ' ';
background-color: #eee;
}
.cropper-center:before {
top: 0;
left: -3px;
width: 7px;
height: 1px;
}
.cropper-center:after {
top: -3px;
left: 0;
width: 1px;
height: 7px;
}
.cropper-face,
.cropper-line,
.cropper-point {
position: absolute;
display: block;
width: 100%;
height: 100%;
opacity: .1;
filter: alpha(opacity=10);
}
.cropper-face {
top: 0;
left: 0;
background-color: #fff;
}
.cropper-line {
background-color: #39f;
}
.cropper-line.line-e {
top: 0;
right: -3px;
width: 5px;
cursor: e-resize;
}
.cropper-line.line-n {
top: -3px;
left: 0;
height: 5px;
cursor: n-resize;
}
.cropper-line.line-w {
top: 0;
left: -3px;
width: 5px;
cursor: w-resize;
}
.cropper-line.line-s {
bottom: -3px;
left: 0;
height: 5px;
cursor: s-resize;
}
.cropper-point {
width: 5px;
height: 5px;
opacity: .75;
background-color: #39f;
filter: alpha(opacity=75);
}
.cropper-point.point-e {
top: 50%;
right: -3px;
margin-top: -3px;
cursor: e-resize;
}
.cropper-point.point-n {
top: -3px;
left: 50%;
margin-left: -3px;
cursor: n-resize;
}
.cropper-point.point-w {
top: 50%;
left: -3px;
margin-top: -3px;
cursor: w-resize;
}
.cropper-point.point-s {
bottom: -3px;
left: 50%;
margin-left: -3px;
cursor: s-resize;
}
.cropper-point.point-ne {
top: -3px;
right: -3px;
cursor: ne-resize;
}
.cropper-point.point-nw {
top: -3px;
left: -3px;
cursor: nw-resize;
}
.cropper-point.point-sw {
bottom: -3px;
left: -3px;
cursor: sw-resize;
}
.cropper-point.point-se {
right: -3px;
bottom: -3px;
width: 20px;
height: 20px;
cursor: se-resize;
opacity: 1;
filter: alpha(opacity=100);
}
.cropper-point.point-se:before {
position: absolute;
right: -50%;
bottom: -50%;
display: block;
width: 200%;
height: 200%;
content: ' ';
opacity: 0;
background-color: #39f;
filter: alpha(opacity=0);
}
@media (min-width: 768px) {
.cropper-point.point-se {
width: 15px;
height: 15px;
}
}
@media (min-width: 992px) {
.cropper-point.point-se {
width: 10px;
height: 10px;
}
}
@media (min-width: 1200px) {
.cropper-point.point-se {
width: 5px;
height: 5px;
opacity: .75;
filter: alpha(opacity=75);
}
}
.cropper-invisible {
opacity: 0;
filter: alpha(opacity=0);
}
.cropper-bg {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
}
.cropper-hide {
position: absolute;
display: block;
width: 0;
height: 0;
}
.cropper-hidden {
display: none !important;
}
.cropper-move {
cursor: move;
}
.cropper-crop {
cursor: crosshair;
}
.cropper-disabled .cropper-drag-box,
.cropper-disabled .cropper-face,
.cropper-disabled .cropper-line,
.cropper-disabled .cropper-point {
cursor: not-allowed;
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment