diff options
Diffstat (limited to 'addons/web_editor/static/src')
7 files changed, 354 insertions, 276 deletions
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; } - - 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; - } + // 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; } - if (isDBImage) { - defs.push(this._rpc({ - route: '/web_editor/get_image_info', - params: params, - }).then(function (res) { - _.extend(self.imageData, res); - })); + + // 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; } - 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.<br/>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'); - - // 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); + _save() { + // Mark the media for later creation/update of cropped attachment + this.media.classList.add('o_cropped_img_to_save'); + + 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 @@ </div> </div> - <!-- Crop Image Dialog (allows to crop image on the page) --> - <div t-name="wysiwyg.widgets.crop_image" class="o_crop_image_dialog"> - <div t-if="widget.imageData.isExternalImage" class="alert alert-warning text-center" role="alert"> - <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> + <!-- ImageCropWidget controls (allows to crop images on the page) --> + <div t-name="wysiwyg.widgets.crop" class="o_we_crop_widget" contenteditable="false"> + <div class="o_we_cropper_wrapper"> + <img class="o_we_cropper_img"/> + <div class="o_we_crop_buttons text-center mt16 position-absolute o_we_no_overlay" contenteditable="false"> + <div class="btn-group btn-group-toggle" title="Aspect Ratio" data-toggle="buttons"> + <t t-foreach="widget.aspectRatios" t-as="ratio"> + <t t-set="is_active" t-value="ratio.value === widget.aspectRatio"/> + <label t-attf-class="{is_active and 'active' or ''}" data-action="ratio" t-att-data-value="ratio"> + <input type="radio" /><t t-esc="ratio_value.label"/> + </label> + </t> + </div> + <div class="btn-group" role="group"> + <button type="button" title="Zoom In" data-action="zoom" data-value="0.1"><i class="fa fa-fw fa-search-plus"/></button> + <button type="button" title="Zoom Out" data-action="zoom" data-value="-0.1"><i class="fa fa-fw fa-search-minus"/></button> + </div> + <div class="btn-group" role="group"> + <button type="button" title="Rotate Left" data-action="rotate" data-value="-90"><i class="fa fa-fw fa-rotate-left"/></button> + <button type="button" title="Rotate Right" data-action="rotate" data-value="90"><i class="fa fa-fw fa-rotate-right"/></button> + </div> + <div class="btn-group" role="group"> + <button type="button" title="Flip Horizontal" data-action="flip" data-scale-direction="scaleX"><i class="fa fa-fw fa-arrows-h"/></button> + <button type="button" title="Flip Vertical" data-action="flip" data-scale-direction="scaleY"><i class="fa fa-fw fa-arrows-v"/></button> + </div> + <div class="btn-group" role="group"> + <button type="button" title="Reset Image" data-action="reset"><i class="fa fa-refresh"/> Reset Image</button> + </div> + <div class="btn-group" role="group"> + <button type="button" title="Apply" data-action="apply" class="btn btn-primary"><i class="fa fa-check"/> Apply</button> + <button type="button" title="Discard" data-action="discard" class="btn btn-danger"><i class="fa fa-times"/> Discard</button> + </div> + </div> </div> - <t t-else=""> - <div class="o_crop_area"> - <img class="img img-fluid o_cropper_image" t-att-src="widget.imageData.originalSrc"/> - </div> - <div class="o_crop_options text-center mt16"> - <div class="btn-group btn-group-toggle" 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"/> - <label t-attf-class="btn btn-secondary#{is_active and ' active' or ''}" data-event="ratio" t-att-data-label="ratio[1]" t-att-data-value="ratio[2]"> - <input type="radio" /><t t-esc="ratio[0]"/> - </label> - </t> - </div> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-secondary" title="Zoom In" data-event="zoom" data-value="0.1"><i class="fa fa-search-plus"/></button> - <button type="button" class="btn btn-secondary" title="Zoom Out" data-event="zoom" data-value="-0.1"><i class="fa fa-search-minus"/></button> - </div> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-secondary" title="Rotate Left" data-event="rotate" data-value="-45"><i class="fa fa-rotate-left"/></button> - <button type="button" class="btn btn-secondary" title="Rotate Right" data-event="rotate" data-value="45"><i class="fa fa-rotate-right"/></button> - </div> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-secondary" title="Flip Horizontal" data-event="flip" data-value="horizontal" data-x="1"><i class="fa fa-arrows-h"/></button> - <button type="button" class="btn btn-secondary" title="Flip Vertical" data-event="flip" data-value="vertical" data-y="1"><i class="fa fa-arrows-v"/></button> - </div> - <div class="btn-group" role="group"> - <button type="button" class="btn btn-secondary" title="Reset Image" data-event="reset"><i class="fa fa-refresh"/> Reset Image</button> - </div> - </div> - </t> </div> </templates> |
