diff options
Diffstat (limited to 'addons/web_editor/static/src/js')
5 files changed, 263 insertions, 211 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, |
