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