[ticket/15769] Crop avatars on upload

PHPBB3-15769
This commit is contained in:
mrgoldy 2020-05-10 00:30:30 +02:00 committed by Marc Alexander
parent eb1edd12a1
commit 4d860bf967
No known key found for this signature in database
GPG key ID: 50E0D2423696F995
17 changed files with 4693 additions and 6 deletions

View file

@ -2,3 +2,70 @@
<dt><label for="avatar_upload_file">{L_UPLOAD_AVATAR_FILE}{L_COLON}</label></dt>
<dd><input type="hidden" name="MAX_FILE_SIZE" value="{AVATAR_UPLOAD_SIZE}" /><input type="file" name="avatar_upload_file" id="avatar_upload_file" class="inputbox autowidth" accept="{{ AVATAR_ALLOWED_EXTENSIONS }}" /></dd>
</dl>
{% if S_CROPPING_AVAILABLE %}
{% INCLUDECSS T_ASSETS_PATH ~ '/css/cropper.min.css' %}
{% INCLUDEJS T_ASSETS_PATH ~ '/javascript/cropper.min.js' %}
{% INCLUDEJS T_ASSETS_PATH ~ '/javascript/jquery-cropper.js' %}
{% INCLUDEJS T_ASSETS_PATH ~ '/javascript/phpbb-avatars.js' %}
<input type="hidden" id="avatar-cropper-data" name="avatar_cropper_data" value=""
data-min-width="{{ AVATAR_MIN_WIDTH }}" data-max-width="{{ AVATAR_MAX_WIDTH }}"
data-min-height="{{ AVATAR_MIN_HEIGHT }}" data-max-height="{{ AVATAR_MAX_HEIGHT }}"
/>
{% apply spaceless %}
<div class="avatar-cropper-buttons" id="avatar-cropper-buttons">
<div class="button-group">
<button class="button" type="button" title="{{ lang('ZOOM_IN') }}" data-cropper-action="zoom,0.1">
<i class="icon fa-search-plus fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('ZOOM_OUT') }}" data-cropper-action="zoom,-0.1">
<i class="icon fa-search-minus fa-fw"></i>
</button>
</div>
<div class="button-group">
<button class="button" type="button" title="{{ lang('MOVE_LEFT') }}" data-cropper-action="move,-10,0">
<i class="icon fa-arrow-left fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('MOVE_RIGHT') }}" data-cropper-action="move,10,0">
<i class="icon fa-arrow-right fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('MOVE_UP') }}" data-cropper-action="move,0,-10">
<i class="icon fa-arrow-up fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('MOVE_DOWN') }}" data-cropper-action="move,0,10">
<i class="icon fa-arrow-down fa-fw"></i>
</button>
</div>
<div class="button-group">
<button class="button" type="button" title="{{ lang('ROTATE_LEFT') }}" data-cropper-action="rotate,-90">
<i class="icon fa-rotate-left fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('ROTATE_RIGHT') }}" data-cropper-action="rotate,90">
<i class="icon fa-rotate-right fa-fw"></i>
</button>
</div>
<div class="button-group">
<button class="button" type="button" title="{{ lang('FLIP_HORIZONTALLY') }}" data-cropper-action="scaleX">
<i class="icon fa-arrows-h fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('FLIP_VERTICALLY') }}" data-cropper-action="scaleY">
<i class="icon fa-arrows-v fa-fw"></i>
</button>
</div>
<div class="button-group">
<button class="button" type="button" title="{{ lang('RESET') }}" data-cropper-action="reset">
<i class="icon fa-refresh fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('CLEAR') }}" data-cropper-action="clear">
<i class="icon fa-times fa-fw"></i>
</button>
</div>
</div>
{% endapply %}
{% endif %}

View file

@ -113,7 +113,7 @@
<legend>{L_GROUP_AVATAR}</legend>
<dl>
<dt><label>{L_CURRENT_IMAGE}{L_COLON}</label><br /><span>{L_AVATAR_EXPLAIN}</span></dt>
<dd>{% if AVATAR_HTML %}{{ AVATAR_HTML }}{% else %}<img src="{{ ADMIN_ROOT_PATH ~ 'images/no_avatar.gif' }}" alt="">{% endif %}</dd>
<dd id="avatar-box">{% if AVATAR_HTML %}{{ AVATAR_HTML }}{% else %}<img src="{{ ADMIN_ROOT_PATH ~ 'images/no_avatar.gif' }}" alt="">{% endif %}</dd>
<dd><label for="avatar_delete"><input type="checkbox" name="avatar_delete" id="avatar_delete" /> {L_DELETE_AVATAR}</label></dd>
</dl>
<dl>

View file

@ -5,7 +5,7 @@
<!-- IF ERROR --><p class="error">{ERROR}</p><!-- ENDIF -->
<dl>
<dt><label>{L_CURRENT_IMAGE}{L_COLON}</label><br /><span>{L_AVATAR_EXPLAIN}</span></dt>
<dd>{% if AVATAR_HTML %}{{ AVATAR_HTML }}{% else %}<img src="{{ ADMIN_ROOT_PATH ~ 'images/no_avatar.gif' }}" alt="">{% endif %}</dd>
<dd id="avatar-box">{% if AVATAR_HTML %}{{ AVATAR_HTML }}{% else %}<img src="{{ ADMIN_ROOT_PATH ~ 'images/no_avatar.gif' }}" alt="">{% endif %}</dd>
<dd><label for="avatar_delete"><input type="checkbox" name="avatar_delete" id="avatar_delete" /> {L_DELETE_AVATAR}</label></dd>
</dl>
</fieldset>

View file

@ -1708,6 +1708,7 @@ input.autowidth {
/* Form button styles
---------------------------------------- */
.button,
a.button1,
input.button1,
a.button2,
@ -1721,6 +1722,7 @@ input.button2 {
cursor: pointer;
}
.button,
a.button1,
input.button1 {
font-weight: bold;
@ -1734,6 +1736,10 @@ input.button2 {
}
/* <a> button in the style of the form buttons */
.button,
.button:link,
.button:visited,
.button:active,
a.button1,
a.button1:link,
a.button1:visited,
@ -1748,6 +1754,7 @@ a.button2:active {
}
/* Hover states */
.button:hover,
a.button1:hover,
input.button1:hover,
a.button2:hover,
@ -1768,6 +1775,37 @@ input.button2:focus {
outline-style: none;
}
/* Avatar cropper */
.avatar-cropper-buttons {
text-align: center;
display: none;
}
/** Button groups */
.button-group {
display: inline-block;
}
.button-group + .button-group {
margin-left: 8px;
}
.button-group > .button:first-child {
border-radius: 4px 0 0 4px;
}
.button-group > .button:last-child {
border-radius: 0 4px 4px 0;
}
.button-group > .button:not(:first-child):not(:last-child) {
border-radius: 0;
}
.avatar-cropper-buttons > .button-group {
margin: 4px;
}
/* jQuery popups
---------------------------------------- */
.phpbb_alert {

View file

@ -0,0 +1,304 @@
/*!
* Cropper.js v1.5.6
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2019-10-04T04:33:44.164Z
*/
.cropper-container {
direction: ltr;
font-size: 0;
line-height: 0;
position: relative;
-ms-touch-action: none;
touch-action: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.cropper-container img {
display: block;
height: 100%;
image-orientation: 0deg;
max-height: none !important;
max-width: none !important;
min-height: 0 !important;
min-width: 0 !important;
width: 100%;
}
.cropper-wrap-box,
.cropper-canvas,
.cropper-drag-box,
.cropper-crop-box,
.cropper-modal {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
.cropper-wrap-box,
.cropper-canvas {
overflow: hidden;
}
.cropper-drag-box {
background-color: #fff;
opacity: 0;
}
.cropper-modal {
background-color: #000;
opacity: 0.5;
}
.cropper-view-box {
display: block;
height: 100%;
outline: 1px solid #39f;
outline-color: rgba(51, 153, 255, 0.75);
overflow: hidden;
width: 100%;
}
.cropper-dashed {
border: 0 dashed #eee;
display: block;
opacity: 0.5;
position: absolute;
}
.cropper-dashed.dashed-h {
border-bottom-width: 1px;
border-top-width: 1px;
height: calc(100% / 3);
left: 0;
top: calc(100% / 3);
width: 100%;
}
.cropper-dashed.dashed-v {
border-left-width: 1px;
border-right-width: 1px;
height: 100%;
left: calc(100% / 3);
top: 0;
width: calc(100% / 3);
}
.cropper-center {
display: block;
height: 0;
left: 50%;
opacity: 0.75;
position: absolute;
top: 50%;
width: 0;
}
.cropper-center::before,
.cropper-center::after {
background-color: #eee;
content: ' ';
display: block;
position: absolute;
}
.cropper-center::before {
height: 1px;
left: -3px;
top: 0;
width: 7px;
}
.cropper-center::after {
height: 7px;
left: 0;
top: -3px;
width: 1px;
}
.cropper-face,
.cropper-line,
.cropper-point {
display: block;
height: 100%;
opacity: 0.1;
position: absolute;
width: 100%;
}
.cropper-face {
background-color: #fff;
left: 0;
top: 0;
}
.cropper-line {
background-color: #39f;
}
.cropper-line.line-e {
cursor: ew-resize;
right: -3px;
top: 0;
width: 5px;
}
.cropper-line.line-n {
cursor: ns-resize;
height: 5px;
left: 0;
top: -3px;
}
.cropper-line.line-w {
cursor: ew-resize;
left: -3px;
top: 0;
width: 5px;
}
.cropper-line.line-s {
bottom: -3px;
cursor: ns-resize;
height: 5px;
left: 0;
}
.cropper-point {
background-color: #39f;
height: 5px;
opacity: 0.75;
width: 5px;
}
.cropper-point.point-e {
cursor: ew-resize;
margin-top: -3px;
right: -3px;
top: 50%;
}
.cropper-point.point-n {
cursor: ns-resize;
left: 50%;
margin-left: -3px;
top: -3px;
}
.cropper-point.point-w {
cursor: ew-resize;
left: -3px;
margin-top: -3px;
top: 50%;
}
.cropper-point.point-s {
bottom: -3px;
cursor: s-resize;
left: 50%;
margin-left: -3px;
}
.cropper-point.point-ne {
cursor: nesw-resize;
right: -3px;
top: -3px;
}
.cropper-point.point-nw {
cursor: nwse-resize;
left: -3px;
top: -3px;
}
.cropper-point.point-sw {
bottom: -3px;
cursor: nesw-resize;
left: -3px;
}
.cropper-point.point-se {
bottom: -3px;
cursor: nwse-resize;
height: 20px;
opacity: 1;
right: -3px;
width: 20px;
}
@media (min-width: 768px) {
.cropper-point.point-se {
height: 15px;
width: 15px;
}
}
@media (min-width: 992px) {
.cropper-point.point-se {
height: 10px;
width: 10px;
}
}
@media (min-width: 1200px) {
.cropper-point.point-se {
height: 5px;
opacity: 0.75;
width: 5px;
}
}
.cropper-point.point-se::before {
background-color: #39f;
bottom: -50%;
content: ' ';
display: block;
height: 200%;
opacity: 0;
position: absolute;
right: -50%;
width: 200%;
}
.cropper-invisible {
opacity: 0;
}
.cropper-bg {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
}
.cropper-hide {
display: block;
height: 0;
position: absolute;
width: 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;
}

9
phpBB/assets/css/cropper.min.css vendored Normal file
View file

@ -0,0 +1,9 @@
/*!
* Cropper.js v1.5.6
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2019-10-04T04:33:44.164Z
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width: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}

File diff suppressed because it is too large Load diff

10
phpBB/assets/javascript/cropper.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,73 @@
/*!
* jQuery Cropper v1.0.1
* https://fengyuanchen.github.io/jquery-cropper
*
* Copyright 2018-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2019-10-19T08:48:33.062Z
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('jquery'), require('cropperjs')) :
typeof define === 'function' && define.amd ? define(['jquery', 'cropperjs'], factory) :
(global = global || self, factory(global.jQuery, global.Cropper));
}(this, function ($, Cropper) { 'use strict';
$ = $ && $.hasOwnProperty('default') ? $['default'] : $;
Cropper = Cropper && Cropper.hasOwnProperty('default') ? Cropper['default'] : Cropper;
if ($ && $.fn && Cropper) {
var AnotherCropper = $.fn.cropper;
var NAMESPACE = 'cropper';
$.fn.cropper = function jQueryCropper(option) {
for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
args[_key - 1] = arguments[_key];
}
var result;
this.each(function (i, element) {
var $element = $(element);
var isDestroy = option === 'destroy';
var cropper = $element.data(NAMESPACE);
if (!cropper) {
if (isDestroy) {
return;
}
var options = $.extend({}, $element.data(), $.isPlainObject(option) && option);
cropper = new Cropper(element, options);
$element.data(NAMESPACE, cropper);
}
if (typeof option === 'string') {
var fn = cropper[option];
if ($.isFunction(fn)) {
result = fn.apply(cropper, args);
if (result === cropper) {
result = undefined;
}
if (isDestroy) {
$element.removeData(NAMESPACE);
}
}
}
});
return result !== undefined ? result : this;
};
$.fn.cropper.Constructor = Cropper;
$.fn.cropper.setDefaults = Cropper.setDefaults;
$.fn.cropper.noConflict = function noConflict() {
$.fn.cropper = AnotherCropper;
return this;
};
}
}));

View file

@ -0,0 +1,10 @@
/*!
* jQuery Cropper v1.0.1
* https://fengyuanchen.github.io/jquery-cropper
*
* Copyright 2018-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2019-10-19T08:48:33.062Z
*/
!function(e,r){"object"==typeof exports&&"undefined"!=typeof module?r(require("jquery"),require("cropperjs")):"function"==typeof define&&define.amd?define(["jquery","cropperjs"],r):r((e=e||self).jQuery,e.Cropper)}(this,function(c,s){"use strict";if(c=c&&c.hasOwnProperty("default")?c.default:c,s=s&&s.hasOwnProperty("default")?s.default:s,c&&c.fn&&s){var e=c.fn.cropper,d="cropper";c.fn.cropper=function(p){for(var e=arguments.length,a=new Array(1<e?e-1:0),r=1;r<e;r++)a[r-1]=arguments[r];var u;return this.each(function(e,r){var t=c(r),n="destroy"===p,o=t.data(d);if(!o){if(n)return;var f=c.extend({},t.data(),c.isPlainObject(p)&&p);o=new s(r,f),t.data(d,o)}if("string"==typeof p){var i=o[p];c.isFunction(i)&&((u=i.apply(o,a))===o&&(u=void 0),n&&t.removeData(d))}}),void 0!==u?u:this},c.fn.cropper.Constructor=s,c.fn.cropper.setDefaults=s.setDefaults,c.fn.cropper.noConflict=function(){return c.fn.cropper=e,this}}});

View file

@ -0,0 +1,174 @@
(function($) { // Avoid conflicts with other libraries
'use strict';
/**
* phpBB Avatars namespace.
*
* Handles cropping for local file uploads.
*/
phpbb.avatars = {
cropper: null,
image: null,
buttons: $('#avatar-cropper-buttons'),
box: $('#avatar-box'),
data: $('#avatar-cropper-data'),
input: $('#avatar_upload_file'),
driver: $('#avatar_driver'),
driverUpload: 'avatar_driver_upload',
/**
* Initialise avatar cropping.
*/
init: function() {
// If the cropper library is not available
if (!$.isFunction($.fn.cropper)) {
return;
}
// Correctly position the cropper buttons
this.buttons.appendTo(this.box);
this.image = this.box.children('img');
this.bindInput();
this.bindSelect();
},
/**
* Destroy (undo) any initialisation.
*/
destroy: function() {
this.buttons.find('[data-cropper-action]').off('click.phpbb.avatars')
this.image.off('crop.phpbb.avatars');
this.data.val('');
this.buttons.hide();
if (this.cropper !== null) {
this.cropper.destroy();
}
},
/**
* Bind a function to the avatar driver <select> element.
*
* If a different driver than the "upload" driver is selected, the cropper is destroyed.
* Otherwise if the "upload" driver is (re-)selected, and it has a value, initialise it.
*/
bindSelect: function() {
this.driver.on('change', function() {
if ($(this).val() === phpbb.avatars.driverUpload) {
if (phpbb.avatars.input.val() !== '') {
phpbb.avatars.input.trigger('change');
}
} else {
phpbb.avatars.destroy();
}
});
},
/**
* Bind a function to the avatar file upload <input> element.
*
* If a file was chosen and it is a valid image file, the cropper is initialised.
* Otherwise the cropper is destroyed.
*/
bindInput: function() {
this.input.on('change', function() {
var fileReader = new FileReader;
if (this.files[0].type.match('image.*')) {
fileReader.readAsDataURL(this.files[0]);
fileReader.onload = function() {
phpbb.avatars.image.cropper('destroy').attr('src', this.result).addClass('avatar');
phpbb.avatars.initCropper();
phpbb.avatars.initButtons();
}
} else {
phpbb.avatars.destroy();
}
});
},
/**
* Bind a function to all the cropper <button> elements.
*
* Only buttons with a data-cropper-action attribute are recognized.
* The value for this data attribute should be a function available in the cropper.
* It also takes two optional parameters, imploded by a comma.
* For example: data-cropper-action="move,0,10" which results in $().cropper('move', 0, 10)
*/
initButtons: function() {
this.buttons.show().find('[data-cropper-action]').off('click.phpbb.avatars').on('click.phpbb.avatars', function() {
var option = $(this).data('cropper-action').split(',');
var action = option[0];
if (typeof phpbb.avatars.cropper[action] === 'function') {
// Special case: flip, set it to the opposite value (-1 and 1).
if (action === 'scaleX' || action === 'scaleY') {
phpbb.avatars.image.cropper(action, - phpbb.avatars.cropper.getData(true)[action]);
} else {
phpbb.avatars.image.cropper(action, option[1], option[2]);
}
}
});
},
/**
* Initialise the cropper (CropperJS).
*
* @see https://github.com/fengyuanchen/cropperjs
*
* This creates a cropper instance with a 1 to 1 (square) aspect ratio,
* automatically creates the maximum available and allowed cropping area,
* and registers a callback function for the 'crop' event.
*/
initCropper: function() {
this.cropper = this.image.cropper({
aspectRatio: 1,
autoCropArea: 1
}).data('cropper');
this.image.off('crop.phpbb.avatars').on('crop.phpbb.avatars', phpbb.avatars.onCrop);
},
/**
* The callback function for the 'crop' event.
*
* This function ensures that the crop area is within the configured dimensions.
* Meaning the width and height can not exceed the limits set by an Administrator.
*
* It also JSON encodes the data array and places it into an <input> element,
* which will be requested server side, and crop the image accordingly.
* Image cropping is done server side, to ensure the best image quality
* and image blobs (from .toBlob()) can only be send through AJAX requests.
*
* @param {object} event
*/
onCrop: function(event) {
var data = phpbb.avatars.data.data();
var width = event.detail.width;
var height = event.detail.height;
if (width < data.minWidth || width > data.maxWidth ||
height < data.minHeight || height > data.maxHeight
) {
phpbb.avatars.cropper.setData({
width: Math.max(data.minWidth, Math.min(data.maxWidth, width)),
height: Math.max(data.minHeight, Math.min(data.maxHeight, height)),
});
}
phpbb.avatars.data.val(JSON.stringify(phpbb.avatars.cropper.getData(true)));
},
};
$(function() {
phpbb.avatars.init();
});
})(jQuery); // Avoid conflicts with other libraries

View file

@ -130,6 +130,7 @@ $lang = array_merge($lang, array(
'CANNOT_REMOVE_FOLDER' => 'This folder cannot be removed.',
'CHANGE_DEFAULT_GROUP' => 'Change default group',
'CHANGE_PASSWORD' => 'Change password',
'CLEAR' => 'Clear',
'CLICK_GOTO_FOLDER' => '%1$sGo to your “%3$s” folder%2$s',
'CLICK_RETURN_FOLDER' => '%1$sReturn to your “%3$s” folder%2$s',
'CONFIRMATION' => 'Confirmation of registration',
@ -222,6 +223,8 @@ $lang = array_merge($lang, array(
'FIELD_INVALID_URL' => 'The field “%s” has an invalid url.',
'FIELD_INVALID_VALUE' => 'The field “%s” has an invalid value.',
'FLIP_HORIZONTALLY' => 'Flip horizontally',
'FLIP_VERTICALLY' => 'Flip vertically',
'FOE_MESSAGE' => 'Message from foe',
'FOES_EXPLAIN' => 'Foes are users which will be ignored by default. Posts by these users will not be fully visible. Private messages from foes are still permitted. Please note that you cannot ignore moderators or administrators.',
'FOES_UPDATED' => 'Your foes list has been updated successfully.',
@ -304,11 +307,13 @@ $lang = array_merge($lang, array(
'MESSAGES_DELETED' => 'Messages successfully deleted',
'MOVE_DELETED_MESSAGES_TO' => 'Move messages from removed folder to',
'MOVE_DOWN' => 'Move down',
'MOVE_LEFT' => 'Move left',
'MOVE_MARKED_TO_FOLDER' => 'Move marked to %s',
'MOVE_PM_ERROR' => array(
1 => 'An error occurred while moving the messages to the new folder, only %2$d out of %1$s was moved.',
2 => 'An error occurred while moving the messages to the new folder, only %2$d out of %1$s were moved.',
),
'MOVE_RIGHT' => 'Move right',
'MOVE_TO_FOLDER' => 'Move to folder',
'MOVE_UP' => 'Move up',
@ -468,6 +473,8 @@ $lang = array_merge($lang, array(
'RESIGN_SELECTED' => 'Resign selected',
'RETURN_FOLDER' => '%1$sReturn to previous folder%2$s',
'RETURN_UCP' => '%sReturn to the User Control Panel%s',
'ROTATE_LEFT' => 'Rotate left',
'ROTATE_RIGHT' => 'Rotate right',
'RULE_ADDED' => 'Rule successfully added.',
'RULE_ALREADY_DEFINED' => 'This rule was defined previously.',
'RULE_DELETED' => 'Rule successfully removed.',
@ -649,6 +656,9 @@ $lang = array_merge($lang, array(
'TO_ME' => 'to me',
),
'ZOOM_IN' => 'Zoom in',
'ZOOM_OUT' => 'Zoom out',
'GROUPS_EXPLAIN' => 'Usergroups enable board admins to better administer users. By default you will be placed in a specific group, this is your default group. This group defines how you may appear to other users, for example your username colouration, avatar, rank, etc. Depending on whether the administrator allows it you may be allowed to change your default group. You may also be placed in or allowed to join other groups. Some groups may give you additional permissions to view content or increase your capabilities in other areas.',
'GROUP_LEADER' => 'Leaderships',
'GROUP_MEMBER' => 'Memberships',

View file

@ -18,6 +18,7 @@ use phpbb\config\config;
use phpbb\controller\helper;
use phpbb\event\dispatcher_interface;
use phpbb\files\factory;
use phpbb\image\image_cropper;
use phpbb\path_helper;
use phpbb\storage\exception\exception as storage_exception;
use phpbb\storage\storage;
@ -100,10 +101,15 @@ class upload extends \phpbb\avatar\driver\driver
return false;
}
$template->assign_vars(array(
'AVATAR_UPLOAD_SIZE' => $this->config['avatar_filesize'],
$use_board = defined('PHPBB_USE_BOARD_URL_PATH') && PHPBB_USE_BOARD_URL_PATH;
$web_path = $use_board ? generate_board_url() . '/' : $this->path_helper->get_web_root_path();
$template->assign_vars([
'AVATAR_ALLOWED_EXTENSIONS' => implode(',', preg_replace('/^/', '.', $this->allowed_extensions)),
));
'AVATAR_UPLOAD_SIZE' => $this->config['avatar_filesize'],
'S_CROPPING_AVAILABLE' => image_cropper::is_available(),
'T_ASSETS_PATH' => $web_path . '/assets',
]);
return true;
}
@ -137,6 +143,7 @@ class upload extends \phpbb\avatar\driver\driver
return false;
}
/** @var \phpbb\files\filespec_storage $file */
$file = $upload->handle_upload('files.types.form_storage', 'avatar_upload_file');
$prefix = $this->config['avatar_salt'] . '_';
@ -150,6 +157,14 @@ class upload extends \phpbb\avatar\driver\driver
return false;
}
// Lets try to crop the avatar
$data = $request->variable('avatar_cropper_data', '', true);
if (!empty($upload_file['name']) && $data && image_cropper::is_file_supported($file))
{
image_cropper::crop_file_by_data($file, json_decode(htmlspecialchars_decode($data, ENT_COMPAT), true));
}
$filedata = array(
'filename' => $file->get('filename'),
'filesize' => $file->get('filesize'),

View file

@ -0,0 +1,261 @@
<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/
namespace phpbb\image;
use phpbb\files\filespec_storage;
/**
* Image cropper for locally uploaded files.
*
* Static class.
* Requires the "ext-gd" PHP extension.
*/
class image_cropper
{
/**
* Check if any image cropping can be done.
*
* @return bool Whether image cropping is available
* @static
*/
public static function is_available(): bool
{
return extension_loaded('gd');
}
/**
* Check if the file extension is supported.
*
* This checks whether the create and save functions for a file extension exists.
* For example the 'imagecreatefrompng' and 'imagepng' functions for a PNG extension.
* Please note that the JPG extension should be in the JPEG form.
* Use @see image_cropper::get_file_extension() to ensure the correct form.
*
* @param string $extension The file extension
* @return bool Whether the file extension is supported or not
* @static
*/
public static function is_extension_supported(string $extension): bool
{
return function_exists(self::get_create_function($extension))
&& function_exists(self::get_save_function($extension));
}
/**
* Check if the file is supported.
*
* Retrieves the file extension from the file,
* and checks if the file extension is supported.
*
* @param filespec_storage $file The locally uploaded file
* @return bool Whether the file is supported or not
* @static
*/
public static function is_file_supported(filespec_storage $file): bool
{
return self::is_extension_supported(self::get_file_extension($file));
}
/**
* Get the file extension.
*
* This ensures that the JPG extension is in the JPEG form.
* Because this form is needed for the create and save functions.
*
* @param filespec_storage $file The locally uploaded file
* @return string The file extension
* @static
*/
public static function get_file_extension(filespec_storage $file): string
{
if ('jpg' === ($extension = strtolower($file->get('extension'))))
{
return 'jpeg';
}
return $extension;
}
/**
* Get the image create function.
*
* @param string $extension The file extension
* @return callable The callable create function
* @static
*/
public static function get_create_function(string $extension): callable
{
return 'imagecreatefrom' . strtolower($extension);
}
/**
* Get the image save function.
*
* @param string $extension The file extension
* @return callable The callable save function
* @static
*/
public static function get_save_function(string $extension): callable
{
return 'image' . strtolower($extension);
}
/**
* Crop the file by specified (cropper) data.
*
* $data = [
* 'x' => (int) Image X offset Required
* 'y' => (int) Image Y offset Required
* 'width' => (int) New image width Required
* 'height' => (int) New image height Required
* 'rotate' => (float) -360.00 to 360.00 Optional
* 'scaleX' => (int) -1 Optional
* 'scaleY' => (int) -1 Optional
* ];
*
* @param filespec_storage $file The locally uploaded file
* @param array $data The (cropper) data
* @return bool Whether the image was successfully saved or not
* @static
*/
public static function crop_file_by_data(filespec_storage $file, array $data): bool
{
$image = self::create_image($file->get('filename'), self::get_file_extension($file));
$rotate = isset($data['rotate']) && (float) $data['rotate'] !== 0;
$flip_x = isset($data['scaleX']) && (int) $data['scaleX'] === -1;
$flip_y = isset($data['scaleY']) && (int) $data['scaleY'] === -1;
if ($rotate)
{
$image = self::rotate_image($image, (float) $data['rotate']);
}
if ($flip_x || $flip_y)
{
$image = self::flip_image($image, $flip_x, $flip_y);
}
$image = self::crop_image_by_array($image, $data);
return self::save_image($file->get('filename'), self::get_file_extension($file), $image);
}
/**
* Create an image.
*
* @param string $file The file path
* @param string $extension The file extension
* @return resource The image resource
* @static
*/
public static function create_image(string $file, string $extension)
{
return self::get_create_function($extension)($file);
}
/**
* Save an image.
*
* @param string $file The file path
* @param string $extension The file extension
* @param resource $image The image resource
* @return bool Whether the image was successfully saved or not
* @static
*/
public static function save_image(string $file, string $extension, $image): bool
{
return self::get_save_function($extension)($image, $file);
}
/**
* Rotate an image.
*
* @param resource $image The image resource
* @param float $degrees The amount of degrees to rotate (-360.00 to 360.00)
* @param int $bg_color The background colour for any part that is left empty
* @return resource The new image resource
* @static
*/
public static function rotate_image($image, float $degrees, int $bg_color = 0)
{
if (false !== ($new_image = imagerotate($image, $degrees, $bg_color)))
{
return $new_image;
}
return $image;
}
/**
* Flip an image.
*
* @param resource $image The image resource
* @param bool $flip_horizontally Whether the image should be flipped horizontally or not
* @param bool $flip_vertically Whether the image should be flipped vertically or not
* @return resource The new image resource
* @static
*/
public static function flip_image($image, bool $flip_horizontally, bool $flip_vertically = false)
{
$flip_mode = 0;
$flip_mode |= $flip_horizontally ? IMG_FLIP_HORIZONTAL : 0;
$flip_mode |= $flip_vertically ? IMG_FLIP_VERTICAL : 0;
if ($flip_mode !== 0)
{
imageflip($image, $flip_mode);
}
return $image;
}
/**
* Crop an image by a data array.
*
* This ensures the correct types (integer) are set.
*
* @param resource $image The image resource
* @param array $data The data array
* @return resource The new image resource
* @static
*/
public static function crop_image_by_array($image, array $data)
{
return self::crop_image($image, (int) $data['x'], (int) $data['y'], (int) $data['width'], (int) $data['height']);
}
/**
* Crop an image.
*
* @param resource $image The image resource
* @param int $x The new image's X offset
* @param int $y The new image's Y offset
* @param int $width The new image's width
* @param int $height The new image's height
* @return resource The new image resource
* @static
*/
public static function crop_image($image, int $x, int $y, int $width, int $height)
{
$new_image = imagecrop($image, [
'x' => $x,
'y' => $y,
'width' => $width,
'height' => $height,
]);
return false !== $new_image ? $new_image : $image;
}
}

View file

@ -8,7 +8,7 @@
<!-- IF ERROR --><p class="error">{ERROR}</p><!-- ENDIF -->
<dl>
<dt><label>{L_CURRENT_IMAGE}{L_COLON}</label><br /><span>{L_AVATAR_EXPLAIN}</span></dt>
<dd><!-- IF AVATAR -->{AVATAR_HTML}<!-- ELSE --><img src="{{ NO_AVATAR_SOURCE }}" alt="" /><!-- ENDIF --></dd>
<dd id="avatar-box"><!-- IF AVATAR -->{AVATAR_HTML}<!-- ELSE --><img src="{{ NO_AVATAR_SOURCE }}" alt="" /><!-- ENDIF --></dd>
<dd><label for="avatar_delete"><input type="checkbox" name="avatar_delete" id="avatar_delete" /> {L_DELETE_AVATAR}</label></dd>
</dl>
</fieldset>

View file

@ -2,3 +2,70 @@
<dt><label for="avatar_upload_file">{L_UPLOAD_AVATAR_FILE}{L_COLON}</label></dt>
<dd><input type="hidden" name="MAX_FILE_SIZE" value="{AVATAR_UPLOAD_SIZE}" /><input type="file" name="avatar_upload_file" id="avatar_upload_file" class="inputbox autowidth" accept="{{ AVATAR_ALLOWED_EXTENSIONS }}" /></dd>
</dl>
{% if S_CROPPING_AVAILABLE %}
{% INCLUDECSS T_ASSETS_PATH ~ '/css/cropper.min.css' %}
{% INCLUDEJS T_ASSETS_PATH ~ '/javascript/cropper.min.js' %}
{% INCLUDEJS T_ASSETS_PATH ~ '/javascript/jquery-cropper.js' %}
{% INCLUDEJS T_ASSETS_PATH ~ '/javascript/phpbb-avatars.js' %}
<input type="hidden" id="avatar-cropper-data" name="avatar_cropper_data" value=""
data-min-width="{{ AVATAR_MIN_WIDTH }}" data-max-width="{{ AVATAR_MAX_WIDTH }}"
data-min-height="{{ AVATAR_MIN_HEIGHT }}" data-max-height="{{ AVATAR_MAX_HEIGHT }}"
/>
{% apply spaceless %}
<div class="avatar-cropper-buttons" id="avatar-cropper-buttons">
<div class="button-group">
<button class="button" type="button" title="{{ lang('ZOOM_IN') }}" data-cropper-action="zoom,0.1">
<i class="icon fa-search-plus fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('ZOOM_OUT') }}" data-cropper-action="zoom,-0.1">
<i class="icon fa-search-minus fa-fw"></i>
</button>
</div>
<div class="button-group">
<button class="button" type="button" title="{{ lang('MOVE_LEFT') }}" data-cropper-action="move,-10,0">
<i class="icon fa-arrow-left fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('MOVE_RIGHT') }}" data-cropper-action="move,10,0">
<i class="icon fa-arrow-right fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('MOVE_UP') }}" data-cropper-action="move,0,-10">
<i class="icon fa-arrow-up fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('MOVE_DOWN') }}" data-cropper-action="move,0,10">
<i class="icon fa-arrow-down fa-fw"></i>
</button>
</div>
<div class="button-group">
<button class="button" type="button" title="{{ lang('ROTATE_LEFT') }}" data-cropper-action="rotate,-90">
<i class="icon fa-rotate-left fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('ROTATE_RIGHT') }}" data-cropper-action="rotate,90">
<i class="icon fa-rotate-right fa-fw"></i>
</button>
</div>
<div class="button-group">
<button class="button" type="button" title="{{ lang('FLIP_HORIZONTALLY') }}" data-cropper-action="scaleX">
<i class="icon fa-arrows-h fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('FLIP_VERTICALLY') }}" data-cropper-action="scaleY">
<i class="icon fa-arrows-v fa-fw"></i>
</button>
</div>
<div class="button-group">
<button class="button" type="button" title="{{ lang('RESET') }}" data-cropper-action="reset">
<i class="icon fa-refresh fa-fw"></i>
</button>
<button class="button" type="button" title="{{ lang('CLEAR') }}" data-cropper-action="clear">
<i class="icon fa-times fa-fw"></i>
</button>
</div>
</div>
{% endapply %}
{% endif %}

View file

@ -42,6 +42,29 @@
vertical-align: top;
}
/** Button groups */
.button-group {
display: inline-block;
}
.button-group + .button-group {
margin-left: 8px;
}
.button-group > .button:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.button-group > .button:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.button-group > .button:not(:first-child):not(:last-child) {
border-radius: 0;
}
/* Posting page styles
---------------------------------------- */
.button-form,
@ -173,3 +196,13 @@ button::-moz-focus-inner {
border: 0;
padding: 0;
}
/* UCP: Avatar cropper */
.avatar-cropper-buttons {
text-align: center;
display: none;
}
.avatar-cropper-buttons > .button-group {
margin: 4px;
}