From c2de755bc690987f59cda27bd83a7429278955fc Mon Sep 17 00:00:00 2001 From: Samuel Degueldre Date: Tue, 31 Mar 2020 07:10:34 +0000 Subject: [PATCH] [IMP] web_editor, *: change image cropping from modal to inline widget *: base Previously, cropping an image was done inside of a modal window, turning it into an inline widget makes the editor feel more integrated and allows you to better see the result of cropping directly in the page, it's also less intrusive to the user's edition workflow. At the same time, the way cropped images are saved was refactored to use the new original_id field on ir_attachment, as the old system was creating problems. Part of https://github.com/odoo/odoo/pull/51517 task-2192755 --- addons/web_editor/controllers/main.py | 53 ++- .../static/src/js/editor/rte.summernote.js | 76 ++-- .../static/src/js/editor/snippets.editor.js | 1 + .../js/wysiwyg/widgets/image_crop_widget.js | 372 +++++++++++------- .../static/src/js/wysiwyg/widgets/media.js | 19 +- .../static/src/js/wysiwyg/widgets/widgets.js | 4 +- .../web_editor/static/src/scss/wysiwyg.scss | 84 ++-- addons/web_editor/static/src/xml/wysiwyg.xml | 72 ++-- addons/website/models/ir_attachment.py | 7 - odoo/addons/base/models/ir_attachment.py | 5 - 10 files changed, 390 insertions(+), 303 deletions(-) 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 @@ - -
-