[MERGE] web_editor: add the image cropping feature

Thanks to this merge, users are now able to crop their images using the
editor. This is done by clicking on a content image and then the crop
button appearing in the editor bar. Cropping an image will only crop the
image at the location it was clicked by creating a new image for that
location. Cropped images do not appear in the media dialog.

task-43706
Closes https://github.com/odoo/odoo/pull/22473
This commit is contained in:
qsm-odoo
2018-05-18 12:16:28 +02:00
8 changed files with 4002 additions and 1 deletions
+17
View File
@@ -245,6 +245,23 @@ class Web_Editor(http.Controller):
attachments_to_remove.unlink()
return removal_blocked_by
## This route is used from CropImageDialog to get image info.
## It is used to display the original image when we crop a previously
## cropped image
@http.route('/web_editor/get_image_info', type='json', auth='user')
def get_image_info(self, image_id=None, xml_id=None):
if xml_id:
record = request.env.ref(xml_id)
elif image_id:
record = request.env['ir.attachment'].browse(image_id)
result = {
'id': record.id,
'mimetype': record.mimetype,
}
if image_id and record.url:
result['src'] = record.url
return result
## The get_assets_editor_resources route is in charge of transmitting the resources the assets
## editor needs to work.
## @param key - the xml_id or id of the view the resources are related to
@@ -0,0 +1,306 @@
/*!
* Cropper v3.1.4
* https://github.com/fengyuanchen/cropper
*
* Copyright (c) 2014-2018 Chen Fengyuan
* Released under the MIT license
*
* Date: 2018-01-13T09:37:21.486Z
*/
.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 {/*Avoid margin top issue (Occur only when margin-top <= -height)
*/
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: .5;
}
.cropper-view-box {
display: block;
height: 100%;
outline-color: rgba(51, 153, 255, 0.75);
outline: 1px solid #39f;
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: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: .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
@@ -157,6 +157,11 @@ renderer.tplPopovers = function (lang, options) {
event: 'delete'
})).appendTo($imageprop);
$(tplIconButton('fa fa-crop', {
title: _t('Crop Image'),
event: 'cropImage',
})).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="img-thumbnail"]'));
$imagePopover.find('.popover-content').append($airPopover.find(".note-history").clone());
$imagePopover.find('[data-event="showImageDialog"]').before($airPopover.find('[data-event="showLinkDialog"]').clone());
@@ -410,6 +415,14 @@ $.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) {
media: $selection.data('target'),
});
};
$.summernote.pluginEvents.cropImage = function (event, editor, layoutInfo, sorted) {
var $editable = layoutInfo.editable();
var $selection = layoutInfo.handle().find('.note-control-selection');
core.bus.trigger('crop_image_dialog_demand', {
$editable: $editable,
media: $selection.data('target'),
});
};
// Utils
var fn_is_void = dom.isVoid || function () {};
@@ -1024,6 +1037,7 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, {
this.setParent(parent);
core.bus.on('alt_dialog_demand', this, this._onAltDialogDemand);
core.bus.on('crop_image_dialog_demand', this, this._onCropImageDialogDemand);
core.bus.on('link_dialog_demand', this, this._onLinkDialogDemand);
core.bus.on('media_dialog_demand', this, this._onMediaDialogDemand);
},
@@ -1034,6 +1048,7 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, {
mixins.EventDispatcherMixin.destroy.call(this);
core.bus.off('alt_dialog_demand', this, this._onAltDialogDemand);
core.bus.off('crop_image_dialog_demand', this, this._onCropImageDialogDemand);
core.bus.off('link_dialog_demand', this, this._onLinkDialogDemand);
core.bus.off('media_dialog_demand', this, this._onMediaDialogDemand);
},
@@ -1066,6 +1081,34 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, {
}
altDialog.open();
},
/**
* Called when a demand to open a crop dialog is received on the bus.
*
* @private
* @param {Object} data
*/
_onCropImageDialogDemand: function (data) {
if (data.__alreadyDone) {
return;
}
data.__alreadyDone = true;
var cropImageDialog = new weWidgets.CropImageDialog(this,
_.extend({
res_model: data.$editable.data('oe-model'),
res_id: data.$editable.data('oe-id'),
}, data.options || {}),
data.$editable,
data.media
);
if (data.onSave) {
cropImageDialog.on('save', this, data.onSave);
}
if (data.onCancel) {
cropImageDialog.on('cancel', this, data.onCancel);
}
cropImageDialog.open();
},
/**
* Called when a demand to open a link dialog is received on the bus.
*
@@ -640,6 +640,17 @@ var SnippetsMenu = Widget.extend({
this.$editable.find('[contentEditable]')
.removeAttr('contentEditable')
.removeProp('contentEditable');
// Removing query_string used to invalidate cache during image crop
_.each(this.$editable.find('.o_cropped_img_to_clean'), function (croppedImg) {
var $croppedImg = $(croppedImg);
var s = $croppedImg.attr('src').split('?');
var src = s[0];
var urlParams = s[1] || '';
urlParams = $.param(_.omit($.deparam(urlParams), 'unique'));
$croppedImg.attr('src', src + (urlParams ? ('?' + urlParams) : ''));
$croppedImg.removeClass('o_cropped_img_to_clean');
});
},
//--------------------------------------------------------------------------
@@ -1,11 +1,11 @@
odoo.define('web_editor.widget', function (require) {
'use strict';
var ajax = require('web.ajax');
var base = require('web_editor.base');
var core = require('web.core');
var Dialog = require('web.Dialog');
var Widget = require('web.Widget');
var utils = require('web.utils');
var weContext = require("web_editor.context");
var QWeb = core.qweb;
@@ -337,6 +337,14 @@ var ImageWidget = MediaWidget.extend({
self.options.onUpload([img]);
}
// Remove crop related attributes
if (self.$media.attr('data-aspect-ratio')) {
var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y'];
_.each(attrs, function (attr) {
self.$media.removeData(attr);
self.$media.removeAttr('data-' + attr);
});
}
return self.media;
});
},
@@ -1541,10 +1549,275 @@ var LinkDialog = Dialog.extend({
},
});
/**
* CropImageDialog widget. Let users crop an image.
*/
var CropImageDialog = Dialog.extend({
template: 'web_editor.dialog.crop_image',
xmlDependencies: Dialog.prototype.xmlDependencies.concat(
['/web_editor/static/src/xml/editor.xml']
),
jsLibs: [
'/web_editor/static/lib/cropper/js/cropper.js',
],
cssLibs: [
'/web_editor/static/lib/cropper/css/cropper.css',
],
events : _.extend({}, Dialog.prototype.events, {
'click .o_crop_options [data-event]': '_onCropOptionClick',
}),
/**
* @constructor
*/
init: function (parent, options, $editable, media) {
this.media = media;
this.$media = $(this.media);
var srcSplit = this.$media.attr('src').split('?');
var src = srcSplit[0];
this.imageData = {
initialSrc: src,
src: src, // the original src for cropped DB images will be fetched later
srcParams: $.deparam(srcSplit[1] || ''),
mimetype: _.str.endsWith(src, '.png') ? 'image/png' : 'image/jpeg', // the mimetype for DB images will be fetched later
aspectRatio: this.$media.data('aspectRatio') || 0,
isExternalImage: src[0] !== '/' && src.indexOf(window.location.host) < 0,
};
this.aspectRatioList = [
[_t("Free"), 0],
['16:9', 16 / 9],
['4:3', 4 / 3],
['1:1', 1],
['2:3', 2 / 3],
];
this.options = _.extend({
title: _t("Crop Image"),
buttons: this.imageData.isExternalImage ? [{
text: _t("Close"),
close: true,
}] : [{
text: _t("Save"),
classes: 'btn-primary',
click: this.save,
}, {
text: _t("Discard"),
close: true,
}],
}, options || {});
this._super(parent, this.options);
},
/**
* @override
*/
willStart: function () {
var self = this;
var def = this._super.apply(this, arguments);
if (this.imageData.isExternalImage) {
return def;
}
var defs = [def, ajax.loadLibs(this)];
var params = {};
var isDBImage = false;
var matchImageID = this.imageData.src.match(/^\/web\/image\/(\d+)/);
if (matchImageID) {
params['image_id'] = parseInt(matchImageID[1]);
isDBImage = true;
} else {
var matchXmlID = this.imageData.src.match(/^\/web\/image\/([^/?]+)/);
if (matchXmlID) {
params['xml_id'] = matchXmlID[1];
isDBImage = true;
}
}
if (isDBImage) {
defs.push(this._rpc({
route: '/web_editor/get_image_info',
params: params,
}).then(function (res) {
_.extend(self.imageData, res);
}));
}
return $.when.apply($, defs);
},
/**
* @override
*/
start: function () {
this.$cropperImage = this.$('.o_cropper_image');
if (this.$cropperImage.length) {
this.$cropperImage.cropper({
viewMode: 1,
autoCropArea: 1,
data: _.pick(this.$media.data(), 'aspectRatio', 'x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY')
});
}
return this._super.apply(this, arguments);
},
/**
* @override
*/
destroy: function () {
if (this.$cropperImage.length) {
this.$cropperImage.cropper('destroy');
}
this._super.apply(this, arguments);
},
/**
* Stores the cropped image in database.
*
* @private
* @returns {Deferred}
*/
save: function () {
var _super = this._super.bind(this);
var def;
if (this.imageData.initialSrc === this.imageData.src) {
// When this is a new image being cropped
def = this._createImage();
} else {
// When this is a previously cropped image
def = this._updateImage();
}
return def.then(_super);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Gets the base64 data from the cropper canvas.
*
* @private
* @returns {string} base64 image data
*/
_getImageBase64: function () {
var cropperData = this.$cropperImage.cropper('getData');
var canvas = this.$cropperImage.cropper('getCroppedCanvas', {
width: cropperData.width,
height: cropperData.height,
});
return canvas.toDataURL(this.imageData.mimetype).split(',')[1];
},
/**
* Creates a new attachment for the cropped image data and updates the image
* with the correct URL.
*
* @private
* @returns {Deferred}
*/
_createImage: function () {
var self = this;
var name = this.imageData.src + '.crop';
return this._rpc({
model: 'ir.attachment',
method: 'create',
args: [{
res_model: this.options.res_model,
res_id: this.options.res_id,
name: name,
datas_fname: name,
datas: this._getImageBase64(),
mimetype: this.imageData.mimetype,
url: this.imageData.src, // To save the original image that was cropped
}],
}).then(function (attachmentID) {
return self._rpc({
model: 'ir.attachment',
method: 'generate_access_token',
args: [[attachmentID]],
}).then(function (access_token) {
var params = _.omit(self.imageData.srcParams, 'unique');
params.access_token = access_token[0];
self.$media.attr('src', '/web/image/' + attachmentID + '?' + $.param(params));
self._setCropBoxData();
});
});
},
/**
* Writes the cropped image attachment with the new data and forces a
* refresh of the image.
*
* @private
* @returns {Deferred}
*/
_updateImage: function () {
var self = this;
return this._rpc({
model: 'ir.attachment',
method: 'write',
args: [[this.imageData.id], {datas: this._getImageBase64()}],
}).then(function () {
self.$media.addClass('o_cropped_img_to_clean');
var params = _.clone(self.imageData.srcParams);
params.unique = new Date().getTime();
self.$media.attr('src', '/web/image/' + self.imageData.id + '?' + $.param(params));
self._setCropBoxData();
});
},
/**
* Saves current crop configuration data as image attributes for future
* edition.
*
* @private
*/
_setCropBoxData: function () {
var self = this;
var cropBoxData = _.extend({
aspectRatio: this.imageData.aspectRatio, // Library's getData does not export aspect ratio
}, this.$cropperImage.cropper('getData'));
_.each(cropBoxData, function (value, key) {
key = _.str.dasherize(key);
self.$media.attr('data-' + key, value);
self.$media.data(key, value);
});
this.$media.trigger('content_changed');
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when a crop option is clicked -> change the crop area accordingly.
*
* @private
* @param {MouseEvent} ev
*/
_onCropOptionClick: function (ev) {
ev.preventDefault();
var $option = $(ev.currentTarget);
var opt = $option.data('event');
var value = $option.data('value');
switch (opt) {
case 'ratio':
$option.siblings().removeClass('active');
$option.addClass('active');
this.$cropperImage.cropper('reset');
this.imageData.aspectRatio = value;
this.$cropperImage.cropper('setAspectRatio', this.imageData.aspectRatio);
break;
case 'zoom':
case 'rotate':
case 'reset':
this.$cropperImage.cropper(opt, value);
break;
case 'flip':
var direction = value === 'horizontal' ? 'x' : 'y';
var scaleAngle = -$option.data(direction);
$option.data(direction, scaleAngle);
this.$cropperImage.cropper('scale' + direction.toUpperCase(), scaleAngle);
break;
}
},
});
return {
Dialog: Dialog,
AltDialog: AltDialog,
MediaDialog: MediaDialog,
LinkDialog: LinkDialog,
CropImageDialog: CropImageDialog,
};
});
@@ -1664,4 +1664,25 @@ body .modal {
border-left: 1px solid $gray-lighter;
}
}
// Crop Dialog
.o_crop_image_dialog .o_crop_area {
height: 350px;
.cropper-point {
background-color: white;
@include o-transition(all, 400ms, $o-we-md-ease);
@include o-transform(scale(1.5, 1.5));
border-radius: 10px;
box-shadow: 0 0 1px 1px rgba(23, 23, 23, 0.5);
opacity: 1;
&:hover {
@include o-transform(scale(2.5, 2.5));
}
}
.cropper-line {
background: black;
}
.cropper-view-box {
outline: 2px solid white;
}
}
}
@@ -300,6 +300,48 @@
</div>
</div>
<!-- Crop Image Dialog (allows to crop image on the page) -->
<div t-name="web_editor.dialog.crop_image" class="o_crop_image_dialog">
<div t-if="widget.imageData.isExternalImage" class="alert alert-warning text-center">
<i class="fa fa-exclamation-triangle fa-2x"/>
<h4>This image is an external image</h4>
<p>
This type of image is not supported for cropping.<br/>
If you want to crop it, please first download it from the original source and upload it in Odoo.
</p>
</div>
<t t-else="">
<div class="o_crop_area">
<img class="img img-responsive o_cropper_image" t-att-src="widget.imageData.src" />
</div>
<div class="o_crop_options text-center mt16">
<div class="btn-group" title="Aspect Ratio" data-toggle="buttons">
<t t-foreach="widget.aspectRatioList" t-as="ratio">
<t t-set="is_active" t-value="ratio[1] === widget.imageData.aspectRatio"/>
<a href="#" t-attf-class="btn btn-default#{is_active and ' active' or ''}" data-event="ratio" t-att-data-value="ratio[1]">
<t t-esc="ratio[0]"/>
</a>
</t>
</div>
<div class="btn-group" role="group">
<a href="#" class="btn btn-default" title="Zoom In" data-event="zoom" data-value="0.1"><i class="fa fa-search-plus"/></a>
<a href="#" class="btn btn-default" title="Zoom Out" data-event="zoom" data-value="-0.1"><i class="fa fa-search-minus"/></a>
</div>
<div class="btn-group" role="group">
<a href="#" class="btn btn-default" title="Rotate Left" data-event="rotate" data-value="-45"><i class="fa fa-rotate-left"/></a>
<a href="#" class="btn btn-default" title="Rotate Right" data-event="rotate" data-value="45"><i class="fa fa-rotate-right"/></a>
</div>
<div class="btn-group" role="group">
<a href="#" class="btn btn-default" title="Flip Horizontal" data-event="flip" data-value="horizontal" data-x="1"><i class="fa fa-arrows-h"/></a>
<a href="#" class="btn btn-default" title="Flip Vertical" data-event="flip" data-value="vertical" data-y="1"><i class="fa fa-arrows-v"/></a>
</div>
<div class="btn-group" role="group">
<a href="#" class="btn btn-default" title="Reset Image" data-event="reset"><i class="fa fa-refresh"/> Reset Image</a>
</div>
</div>
</t>
</div>
<!--=================-->
<!-- Snippet options -->
<!--=================-->