diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py
index f8b844b176c..c037e783b61 100644
--- a/addons/web_editor/controllers/main.py
+++ b/addons/web_editor/controllers/main.py
@@ -225,27 +225,35 @@ class Web_Editor(http.Controller):
return removal_blocked_by
@http.route('/web_editor/get_image_info', type='json', auth='user', website=True)
- def get_image_info(self, image_id=None, xml_id=None):
- """This route is used from CropImageDialog to get image info.
+ def get_image_info(self, src=''):
+ """This route is used from image crop widget to get image info.
It is used to display the original image when we crop a previously
cropped image.
"""
- if xml_id:
- record = request.env['ir.attachment'].get_attachment_by_key(xml_id)
- elif image_id:
- record = request.env['ir.attachment'].browse(image_id)
- result = {
- 'mimetype': record.mimetype,
+ id_match = re.search('^/web/image/([^/?]+)', src)
+ attachment = []
+ if id_match:
+ url_segment = id_match.group(1)
+ number_match = re.match('^(\d+)', url_segment)
+ if '.' in url_segment: # xml-id
+ attachment = request.env['ir.http']._xmlid_to_obj(request.env, url_segment)
+ elif number_match: # numeric id
+ attachment = request.env['ir.attachment'].browse(int(number_match.group(1)))
+ else:
+ # Find attachment by url. There can be multiple matches because of default
+ # snippet images referencing the same image in /static/, so we limit to 1
+ attachment = request.env['ir.attachment'].search([('url', '=like', src)], limit=1)
+ if not attachment:
+ return {
+ 'attachment': False,
+ 'original': False,
+ }
+ return {
+ 'attachment': attachment.read(['id'])[0],
+ 'original': (attachment.original_id or attachment).read(['id', 'image_src', 'mimetype', 'name'])[0],
}
- # If we received the image ID and that image has an associated URL
- # field, this should be a crop image attachment, so we return the ID
- # and URL to confirm
- if image_id and record.url:
- result['id'] = record.id
- result['originalSrc'] = record.url
- return result
- def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view'):
+ def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view', mimetype=None, original_id=None):
"""Create and return a new attachment."""
if not name and url:
name = url.split("/").pop()
@@ -260,6 +268,8 @@ class Web_Editor(http.Controller):
'public': res_model == 'ir.ui.view',
'res_id': res_id,
'res_model': res_model,
+ 'mimetype': mimetype,
+ 'original_id': original_id,
}
if data:
@@ -474,3 +484,14 @@ class Web_Editor(http.Controller):
and xmlid in request.env['web_editor.assets']._get_public_asset_xmlids():
View = View.sudo()
return View._render_template(xmlid, {k: values[k] for k in values if k in trusted_value_keys})
+
+ @http.route("/web_editor/crop_attachment", type="json", auth="user", website=True)
+ def crop_attachment(self, res_model=None, res_id=None, name=None, data=None, mimetype=None, original_id=None):
+ """
+ Creates a cropped attachment and returns its image_src to be inserted into the DOM
+ """
+ attachment = self._attachment_create(res_model=res_model, res_id=res_id, name=name, data=data, mimetype=mimetype, original_id=original_id)
+ if attachment.public:
+ return attachment.image_src
+ attachment.generate_access_token()
+ return '%s?access_token=%s' % (attachment.image_src, attachment.access_token)
diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js
index 514dfdfe85c..92d8cefe0bc 100644
--- a/addons/web_editor/static/src/js/editor/rte.summernote.js
+++ b/addons/web_editor/static/src/js/editor/rte.summernote.js
@@ -460,7 +460,7 @@ $.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) {
$.summernote.pluginEvents.cropImage = function (event, editor, layoutInfo, sorted) {
var $editable = layoutInfo.editable();
var $selection = layoutInfo.handle().find('.note-control-selection');
- topBus.trigger('crop_image_dialog_demand', {
+ topBus.trigger('crop_image_demand', {
$editable: $editable,
media: $selection.data('target'),
});
@@ -1075,7 +1075,7 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin,
this.setParent(parent);
topBus.on('alt_dialog_demand', this, this._onAltDialogDemand);
- topBus.on('crop_image_dialog_demand', this, this._onCropImageDialogDemand);
+ topBus.on('crop_image_demand', this, this._onCropImageDemand);
topBus.on('link_dialog_demand', this, this._onLinkDialogDemand);
topBus.on('media_dialog_demand', this, this._onMediaDialogDemand);
},
@@ -1086,7 +1086,7 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin,
mixins.EventDispatcherMixin.destroy.call(this);
topBus.off('alt_dialog_demand', this, this._onAltDialogDemand);
- topBus.off('crop_image_dialog_demand', this, this._onCropImageDialogDemand);
+ topBus.off('crop_image_demand', this, this._onCropImageDemand);
topBus.off('link_dialog_demand', this, this._onLinkDialogDemand);
topBus.off('media_dialog_demand', this, this._onMediaDialogDemand);
},
@@ -1099,44 +1099,25 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin,
*/
saveCroppedImages: function ($editable) {
var defs = _.map($editable.find('.o_cropped_img_to_save'), async croppedImg => {
- var $croppedImg = $(croppedImg);
- $croppedImg.removeClass('o_cropped_img_to_save');
-
- var resModel = $croppedImg.data('crop:resModel');
- var resID = $croppedImg.data('crop:resID');
- var cropID = $croppedImg.data('crop:id');
- var mimetype = $croppedImg.data('crop:mimetype');
- var originalSrc = $croppedImg.data('crop:originalSrc');
-
- var datas = $croppedImg.attr('src').split(',')[1];
- let attachmentID = cropID;
- if (!cropID) {
- var name = originalSrc + '.crop';
- attachmentID = await this._rpc({
- model: 'ir.attachment',
- method: 'create',
- args: [{
- res_model: resModel,
- res_id: resID,
- name: name,
- datas: datas,
- mimetype: mimetype,
- url: originalSrc, // To save the original image that was cropped
- }],
- });
- } else {
- await this._rpc({
- model: 'ir.attachment',
- method: 'write',
- args: [[cropID], {datas: datas}],
- });
- }
- const access_token = await this._rpc({
- model: 'ir.attachment',
- method: 'generate_access_token',
- args: [[attachmentID]],
+ croppedImg.classList.remove('o_cropped_img_to_save');
+ // Cropping an image always creates a copy of the original, even if
+ // it was cropped previously, as the other cropped image may be used
+ // elsewhere if the snippet was duplicated or was saved as a custom one.
+ const croppedAttachmentSrc = await this._rpc({
+ route: '/web_editor/crop_attachment',
+ params: {
+ res_model: croppedImg.dataset.resModel,
+ res_id: parseInt(croppedImg.dataset.resId),
+ name: croppedImg.dataset.originalName + '.crop',
+ data: croppedImg.getAttribute('src').split(',')[1],
+ mimetype: croppedImg.dataset.mimetype,
+ original_id: parseInt(croppedImg.dataset.originalId),
+ },
+ });
+ croppedImg.setAttribute('src', croppedAttachmentSrc);
+ weWidgets.ImageCropWidget.prototype.removeOnSaveAttributes.forEach(attr => {
+ delete croppedImg.dataset[attr];
});
- $croppedImg.attr('src', '/web/image/' + attachmentID + '?access_token=' + access_token[0]);
});
return Promise.all(defs);
},
@@ -1169,30 +1150,23 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin,
altDialog.open();
},
/**
- * Called when a demand to open a crop dialog is received on the bus.
+ * Called when a demand to crop an image is received on the bus.
*
* @private
* @param {Object} data
*/
- _onCropImageDialogDemand: function (data) {
+ _onCropImageDemand: function (data) {
if (data.__alreadyDone) {
return;
}
data.__alreadyDone = true;
- var cropImageDialog = new weWidgets.CropImageDialog(this,
+ new weWidgets.ImageCropWidget(this,
_.extend({
res_model: data.$editable.data('oe-model'),
res_id: data.$editable.data('oe-id'),
}, data.options || {}),
data.media
- );
- if (data.onSave) {
- cropImageDialog.on('save', this, data.onSave);
- }
- if (data.onCancel) {
- cropImageDialog.on('cancel', this, data.onCancel);
- }
- cropImageDialog.open();
+ ).appendTo(data.$editable);
},
/**
* Called when a demand to open a link dialog is received on the bus.
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
index 762bade5e07..06263b23182 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -874,6 +874,7 @@ var SnippetsMenu = Widget.extend({
'.o_we_no_overlay',
'.ui-autocomplete',
'.modal .close',
+ '.o_we_crop_widget',
].join(', ');
},
/**
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
index 9f827410221..91f76bba5d4 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
@@ -1,174 +1,226 @@
-odoo.define('wysiwyg.widgets.CropImageDialog', function (require) {
+odoo.define('wysiwyg.widgets.ImageCropWidget', function (require) {
'use strict';
-var core = require('web.core');
-var Dialog = require('wysiwyg.widgets.Dialog');
+const core = require('web.core');
+const qweb = core.qweb;
+const Widget = require('web.Widget');
-var _t = core._t;
+const _t = core._t;
-/**
- * CropImageDialog widget. Let users crop an image.
- */
-var CropImageDialog = Dialog.extend({
- template: 'wysiwyg.widgets.crop_image',
- xmlDependencies: Dialog.prototype.xmlDependencies.concat(
- ['/web_editor/static/src/xml/wysiwyg.xml']
- ),
- events: _.extend({}, Dialog.prototype.events, {
- 'click .o_crop_options [data-event]': '_onCropOptionClick',
- }),
+// Fields returned by cropper lib 'getData' method
+const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY'];
+const ImageCropWidget = Widget.extend({
+ template: ['wysiwyg.widgets.crop'],
+ xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
+ events: {
+ 'click.crop_options [data-action]': '_onCropOptionClick',
+ // zoom event is triggered by the cropperjs library when the user zooms.
+ 'zoom': '_onCropZoom',
+ },
+ // Crop attributes that are saved with the DOM. Should only be removed when the image is changed.
+ persistentAttributes: [
+ ...cropperDataFields,
+ 'aspectRatio',
+ ],
+ // Attributes that are used to keep data from one crop to the next in the same session
+ // If present, should be used by the cropper instead of querying db
+ sessionAttributes: [
+ 'attachmentId',
+ 'originalSrc',
+ 'originalId',
+ 'originalName',
+ 'mimetype',
+ ],
+ // Attributes that are used by saveCroppedImages to create or update attachments
+ saveAttributes: [
+ 'resModel',
+ 'resId',
+ 'attachmentId',
+ 'originalId',
+ 'originalName',
+ 'mimetype',
+ ],
/**
* @constructor
*/
- init: function (parent, options, media) {
- var self = this;
+ init(parent, options, media) {
+ this._super(...arguments);
this.media = media;
- this.$media = $(this.media);
- var src = this.$media.attr('src').split('?')[0];
- this.aspectRatioList = [
- [_t("Free"), '0/0', 0],
- ["16:9", '16/9', 16 / 9],
- ["4:3", '4/3', 4 / 3],
- ["1:1", '1/1', 1],
- ["2:3", '2/3', 2 / 3],
- ];
- this.imageData = {
- imageSrc: src,
- originalSrc: this.$media.data('crop:originalSrc') || src, // the original src for cropped DB images will be fetched later
- mimetype: this.$media.data('crop:mimetype') || (_.str.endsWith(src, '.png') ? 'image/png' : 'image/jpeg'), // the mimetype for DB images will be fetched later
- aspectRatio: this.$media.data('aspectRatio') || this.aspectRatioList[0][1],
- isExternalImage: src.substr(0, 5) !== 'data:' && src[0] !== '/' && src.indexOf(window.location.host) < 0,
+ this.$media = $(media);
+ // Needed for editors in iframes.
+ this.document = media.ownerDocument;
+ // Used for res_model and res_id
+ this.options = options;
+ // key: ratio identifier, label: displayed to user, value: used by cropper lib
+ this.aspectRatios = {
+ "0/0": {label: _t("Free"), value: 0},
+ "16/9": {label: "16:9", value: 16 / 9},
+ "4/3": {label: "4:3", value: 4 / 3},
+ "1/1": {label: "1:1", value: 1},
+ "2/3": {label: "2:3", value: 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);
- this.trigger_up('getRecordInfo', _.extend(this.options, {
- callback: function (recordInfo) {
- _.defaults(self.options, recordInfo);
- },
- }));
+ const src = this.media.getAttribute('src');
+ const data = Object.assign({}, media.dataset);
+ this.initialSrc = src;
+ this.aspectRatio = data.aspectRatio || "0/0";
+ this.mimetype = data.mimetype || src.endsWith('.png') ? 'image/png' : 'image/jpeg';
+ this.isCroppable = src.startsWith('data:') || new URL(src, window.location.origin).origin === window.location.origin;
},
/**
* @override
*/
- willStart: function () {
- var self = this;
- var def = this._super.apply(this, arguments);
- if (this.imageData.isExternalImage) {
- return def;
+ async willStart() {
+ await this._super.apply(this, arguments);
+ if (!this.isCroppable) {
+ return;
+ }
+ // If there is a marked originalSrc, a previous crop has already happened,
+ // we won't find the original from the data-url. Reuse the data from the previous crop.
+ if (this.media.dataset.originalSrc) {
+ this.sessionAttributes.forEach(attr => {
+ this[attr] = this.media.dataset[attr];
+ });
+ return;
}
- var defs = [def];
- var params = {};
- var isDBImage = false;
- var matchImageID = this.imageData.imageSrc.match(/^\/web\/image\/(\d+)/);
- if (matchImageID) {
- params.image_id = parseInt(matchImageID[1]);
- isDBImage = true;
- } else {
- var matchXmlID = this.imageData.imageSrc.match(/^\/web\/image\/([^/?]+)/);
- if (matchXmlID) {
- params.xml_id = matchXmlID[1];
- isDBImage = true;
- }
+ // Get id, mimetype and originalSrc.
+ const {attachment, original} = await this._rpc({
+ route: '/web_editor/get_image_info',
+ params: {src: this.initialSrc.split(/[?#]/)[0]},
+ });
+ if (!attachment) {
+ // Local image that doesn't have an attachment, don't allow crop?
+ // In practice, this can happen if an image is directly linked with its
+ // static url and there is no corresponding attachment, (eg, logo in mass_mailing)
+ this.isCroppable = false;
+ return;
}
- if (isDBImage) {
- defs.push(this._rpc({
- route: '/web_editor/get_image_info',
- params: params,
- }).then(function (res) {
- _.extend(self.imageData, res);
- }));
- }
- return Promise.all(defs);
+ this.originalId = original.id;
+ this.originalSrc = original.image_src;
+ this.originalName = original.name;
+ this.mimetype = original.mimetype;
+ this.attachmentId = attachment.id;
},
/**
* @override
*/
- start: function () {
- this.$cropperImage = this.$('.o_cropper_image');
- if (this.$cropperImage.length) {
- var data = this.$media.data();
- var ratio = 0;
- for (var i = 0; i < this.aspectRatioList.length; i++) {
- if (this.aspectRatioList[i][1] === data.aspectRatio) {
- ratio = this.aspectRatioList[i][2];
- break;
- }
- }
- this.$cropperImage.cropper({
- viewMode: 2,
- dragMode: 'move',
- autoCropArea: 1.0,
- aspectRatio: ratio,
- data: _.pick(data, 'x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY')
+ async start() {
+ if (!this.isCroppable) {
+ this.displayNotification({
+ type: 'warning',
+ title: _t("This image is an external image"),
+ message: _t("This type of image is not supported for cropping.
If you want to crop it, please first download it from the original source and upload it in Odoo."),
});
+ return this.destroy();
}
- return this._super.apply(this, arguments);
+ const _super = this._super.bind(this);
+ const $cropperWrapper = this.$('.o_we_cropper_wrapper');
+
+ // Replacing the src with the original's so that the layout is correct.
+ this.media.setAttribute('src', this.originalSrc);
+ await new Promise(resolve => this.media.addEventListener('load', resolve, {once: true}));
+ this.$cropperImage = this.$('.o_we_cropper_img');
+ const cropperImage = this.$cropperImage[0];
+ [cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px'];
+
+ // Overlaying the cropper image over the real image
+ const offset = this.$media.offset();
+ offset.left += parseInt(this.$media.css('padding-left'));
+ offset.top += parseInt(this.$media.css('padding-right'));
+ $cropperWrapper.offset(offset);
+
+ cropperImage.setAttribute('src', this.originalSrc);
+ await new Promise(resolve => cropperImage.addEventListener('load', resolve, {once: true}));
+ this.$cropperImage.cropper({
+ viewMode: 2,
+ dragMode: 'move',
+ autoCropArea: 1.0,
+ aspectRatio: this.aspectRatios[this.aspectRatio].value,
+ data: _.mapObject(_.pick(this.media.dataset, ...cropperDataFields), value => parseFloat(value)),
+ // Can't use 0 because it's falsy and the lib will then use its defaults (200x100)
+ minContainerWidth: 1,
+ minContainerHeight: 1,
+ });
+ core.bus.trigger('deactivate_snippet');
+
+ this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
+ // We use capture so that the handler is called before other editor handlers
+ // like save, such that we can restore the src before a save.
+ this.document.addEventListener('mousedown', this._onDocumentMousedown, {capture: true});
+ return _super(...arguments);
},
/**
* @override
*/
- destroy: function () {
- if (this.$cropperImage.length) {
+ destroy() {
+ if (this.$cropperImage) {
this.$cropperImage.cropper('destroy');
+ this.document.removeEventListener('mousedown', this._onDocumentMousedown, {capture: true});
}
- this._super.apply(this, arguments);
+ this.media.setAttribute('src', this.initialSrc);
+ return this._super(...arguments);
},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
/**
* Updates the DOM image with cropped data and associates required
* information for a potential future save (where required cropped data
* attachments will be created).
*
- * @override
+ * @private
*/
- save: function () {
- var self = this;
- var cropperData = this.$cropperImage.cropper('getData');
+ _save() {
+ // Mark the media for later creation/update of cropped attachment
+ this.media.classList.add('o_cropped_img_to_save');
- // Mark the media for later creation of required cropped attachments...
- this.$media.addClass('o_cropped_img_to_save');
-
- // ... and attach required data
- this.$media.data('crop:resModel', this.options.res_model);
- this.$media.data('crop:resID', this.options.res_id);
- this.$media.data('crop:id', this.imageData.id);
- this.$media.data('crop:mimetype', this.imageData.mimetype);
- this.$media.data('crop:originalSrc', this.imageData.originalSrc);
-
- // Mark the media with the cropping information which is required for
- // a future crop edition
- this.$media
- .attr('data-aspect-ratio', this.imageData.aspectRatio)
- .data('aspectRatio', this.imageData.aspectRatio);
- _.each(cropperData, function (value, key) {
- key = _.str.dasherize(key);
- self.$media.attr('data-' + key, value);
- self.$media.data(key, value);
+ this.allAttributes.forEach(attr => {
+ delete this.media.dataset[attr];
+ const value = this._getAttributeValue(attr);
+ if (value) {
+ this.media.dataset[attr] = value;
+ }
});
// Update the media with base64 source for preview before saving
- var canvas = this.$cropperImage.cropper('getCroppedCanvas', {
+ const cropperData = this.$cropperImage.cropper('getData');
+ const canvas = this.$cropperImage.cropper('getCroppedCanvas', {
width: cropperData.width,
height: cropperData.height,
});
- this.$media.attr('src', canvas.toDataURL(this.imageData.mimetype));
-
- this.final_data = this.media;
- return this._super.apply(this, arguments);
+ // 1 is the quality if the image is jpeg (in the range O-1), defaults to .92
+ this.initialSrc = canvas.toDataURL(this.mimetype, 1);
+ // src will be set to this.initialSrc in the destroy method
+ this.destroy();
+ },
+ /**
+ * Returns an attribute's value for saving.
+ *
+ * @private
+ */
+ _getAttributeValue(attr) {
+ switch (attr) {
+ case 'resModel':
+ return this.options.res_model;
+ case 'resId':
+ return this.options.res_id;
+ }
+ if (cropperDataFields.includes(attr)) {
+ return this.$cropperImage.cropper('getData')[attr];
+ }
+ return this[attr];
+ },
+ /**
+ * Resets the crop box to prevent it going outside the image.
+ *
+ * @private
+ */
+ _resetCropBox() {
+ this.$cropperImage.cropper('clear');
+ this.$cropperImage.cropper('crop');
},
//--------------------------------------------------------------------------
@@ -181,31 +233,65 @@ var CropImageDialog = Dialog.extend({
* @private
* @param {MouseEvent} ev
*/
- _onCropOptionClick: function (ev) {
- var $option = $(ev.currentTarget);
- var opt = $option.data('event');
- var value = $option.data('value');
- switch (opt) {
+ _onCropOptionClick(ev) {
+ const {action, value, scaleDirection} = ev.currentTarget.dataset;
+ switch (action) {
case 'ratio':
this.$cropperImage.cropper('reset');
- this.imageData.aspectRatio = $option.data('label');
- this.$cropperImage.cropper('setAspectRatio', value);
+ this.aspectRatio = value;
+ this.$cropperImage.cropper('setAspectRatio', this.aspectRatios[this.aspectRatio].value);
break;
case 'zoom':
- case 'rotate':
case 'reset':
- this.$cropperImage.cropper(opt, value);
+ this.$cropperImage.cropper(action, 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);
+ case 'rotate':
+ this.$cropperImage.cropper(action, value);
+ this._resetCropBox();
break;
+ case 'flip': {
+ const amount = this.$cropperImage.cropper('getData')[scaleDirection] * -1;
+ return this.$cropperImage.cropper(scaleDirection, amount);
+ }
+ case 'apply':
+ return this._save();
+ case 'discard':
+ return this.destroy();
}
},
+ /**
+ * Discards crop if the user clicks outside of the widget.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDocumentMousedown(ev) {
+ if (document.body.contains(ev.target) && this.$(ev.target).length === 0) {
+ return this.destroy();
+ }
+ },
+ /**
+ * Resets the cropbox on zoom to prevent crop box overflowing.
+ *
+ * @private
+ */
+ async _onCropZoom() {
+ // Wait for the zoom event to be fully processed before reseting.
+ await new Promise(res => setTimeout(res, 0));
+ this._resetCropBox();
+ },
});
+const proto = ImageCropWidget.prototype;
+proto.allAttributes = [...new Set([
+ ...proto.persistentAttributes,
+ ...proto.sessionAttributes,
+ ...proto.saveAttributes,
+])];
+proto.removeOnSaveAttributes = [...new Set([
+ ...proto.sessionAttributes,
+ ...proto.saveAttributes,
+])];
-return CropImageDialog;
+return ImageCropWidget;
});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
index c717417f428..645b49c7ead 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
@@ -2,7 +2,6 @@ odoo.define('wysiwyg.widgets.media', function (require) {
'use strict';
var concurrency = require('web.concurrency');
-var config = require('web.config');
var core = require('web.core');
var Dialog = require('web.Dialog');
var dom = require('web.dom');
@@ -11,6 +10,7 @@ var ImageOptimizeDialog = require('wysiwyg.widgets.image_optimize_dialog').Image
var utils = require('web.utils');
var Widget = require('web.Widget');
var session = require('web.session');
+const ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget');
var QWeb = core.qweb;
var _t = core._t;
@@ -497,19 +497,10 @@ var FileWidget = SearchableMediaWidget.extend({
}
// Remove crop related attributes
- if (this.$media.attr('data-aspect-ratio')) {
- var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y'];
- Object.keys(self.$media.data()).forEach(function (key) {
- if (_.str.startsWith(key, 'crop:')) {
- attrs.push(key);
- }
- });
- this.$media.removeClass('o_cropped_img_to_save');
- attrs.forEach(attr => {
- this.$media.removeData(attr);
- this.$media.removeAttr('data-' + attr);
- });
- }
+ ImageCropWidget.prototype.allAttributes.forEach(attr => {
+ delete this.media.dataset[attr];
+ });
+ this.media.classList.remove('o_cropped_img_to_save');
return this.media;
},
/**
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
index 93d1068b949..64a9dc062f3 100644
--- a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
@@ -5,7 +5,7 @@ var Dialog = require('wysiwyg.widgets.Dialog');
var AltDialog = require('wysiwyg.widgets.AltDialog');
var MediaDialog = require('wysiwyg.widgets.MediaDialog');
var LinkDialog = require('wysiwyg.widgets.LinkDialog');
-var CropImageDialog = require('wysiwyg.widgets.CropImageDialog');
+var ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget');
const {ColorpickerDialog} = require('web.Colorpicker');
var media = require('wysiwyg.widgets.media');
@@ -15,7 +15,7 @@ return {
AltDialog: AltDialog,
MediaDialog: MediaDialog,
LinkDialog: LinkDialog,
- CropImageDialog: CropImageDialog,
+ ImageCropWidget: ImageCropWidget,
ColorpickerDialog: ColorpickerDialog,
MediaWidget: media.MediaWidget,
diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss
index c75dce9c03f..b3fa17b9116 100644
--- a/addons/web_editor/static/src/scss/wysiwyg.scss
+++ b/addons/web_editor/static/src/scss/wysiwyg.scss
@@ -336,32 +336,6 @@ body .modal {
}
}
- // Crop Dialog
- .o_crop_image_dialog .o_crop_area {
- height: 350px;
-
- .cropper-point {
- background-color: white;
- transition: all 400ms $o-we-md-ease 0s;
- transform: scale(1.5, 1.5);
- border-radius: 10px;
- box-shadow: 0 0 1px 1px rgba(23, 23, 23, 0.5);
- opacity: 1;
-
- &:hover {
- transform: scale(2.5, 2.5);
- }
- }
-
- .cropper-line {
- background: black;
- }
-
- .cropper-view-box {
- outline: 2px solid white;
- }
- }
-
// LINK EDITOR DIALOG COLOR SELECTOR
.o_link_dialog {
input.link-style:checked + span::after {
@@ -445,3 +419,61 @@ img.o_we_custom_image.mx-auto {
.o_we_no_pointer_events {
pointer-events: none;
}
+
+.o_we_crop_widget {
+ background-color: #8888;
+ @include o-position-absolute(0, 0, 0, 0);
+ z-index: 1024;
+
+ .o_we_cropper_wrapper {
+ position: absolute;
+ }
+
+ .o_we_crop_buttons {
+ margin-top: 0.5rem;
+ display: flex;
+ flex-wrap: wrap;
+
+ input[type=radio] {
+ display: none;
+ }
+
+ .btn-group {
+ border-radius: 0.25rem;
+ margin: 0.1rem;
+ }
+
+ button, label {
+ cursor: pointer !important;
+ padding: 0.2rem 0.3rem;
+ }
+
+ label {
+ display: flex;
+ align-items: center;
+ }
+
+ button:not(.btn), label {
+ margin: 0;
+ border: none;
+ border-right: 1px solid var(--o-we-bg-color-dark);
+ background-color: var(--o-we-bg-color);
+ color: var(--o-we-color);
+
+ &:hover {
+ background-color: var(--o-we-bg-color-dark);
+ }
+
+ &:first-child {
+ border-top-left-radius: 0.25rem;
+ border-bottom-left-radius: 0.25rem;
+ }
+
+ &:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-right-radius: 0.25rem;
+ border-right: none;
+ }
+ }
+ }
+}
diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml
index f23165e8959..a5f4ddb0f74 100644
--- a/addons/web_editor/static/src/xml/wysiwyg.xml
+++ b/addons/web_editor/static/src/xml/wysiwyg.xml
@@ -520,46 +520,40 @@
-
-
- This type of image is not supported for cropping.
- If you want to crop it, please first download it from the original source and upload it in Odoo.
-