From 87a35d42636e31642c8d1d0170c03c2689867a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Tue, 4 Jun 2019 15:11:16 +0200 Subject: [PATCH] [IMP] web, web_editor, *: add media image optimize dialog * = website, website_blog The goal is to give the user an opportunity to optimize his images before using them. For this we introduce a preview/configuration dialog after every image upload, where appropriate default values are filled for the quality and resolution, based on where the image is going to be used. Since this is not going to be perfect all the time, we still allow the user to configure them, and we display a preview to ease this process. Indeed it is important for SEO and for usability in general that the images are as light as possible in size. Technically the original image is uploaded and saved first, and then it can be optimized. This way the upload only happens once, and the preview can be computed from the already saved image. task-1930726 PR: #31208 --- addons/web/controllers/main.py | 7 +- addons/web_editor/controllers/main.py | 27 +- addons/web_editor/models/ir_attachment.py | 18 +- .../static/src/js/wysiwyg/plugin/media.js | 7 + .../wysiwyg/widgets/image_optimize_dialog.js | 310 ++++++++++++++++++ .../static/src/js/wysiwyg/widgets/media.js | 157 +++++++-- .../src/js/wysiwyg/widgets/media_dialog.js | 25 ++ .../js/wysiwyg_snippets/snippets.options.js | 1 + .../web_editor/static/src/scss/wysiwyg.scss | 31 +- addons/web_editor/static/src/xml/wysiwyg.xml | 71 +++- addons/web_editor/static/tests/test_utils.js | 10 + addons/web_editor/views/editor.xml | 1 + .../static/src/js/editor/snippets.options.js | 2 +- addons/website/static/src/js/widgets/theme.js | 4 +- .../static/src/js/website_blog.editor.js | 1 + 15 files changed, 616 insertions(+), 56 deletions(-) create mode 100644 addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 81100e48038..ab7ba022379 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1065,11 +1065,12 @@ class Binary(http.Controller): # other kwargs are ignored on purpose return self._content_image(xmlid=xmlid, model=model, id=id, field=field, filename_field=filename_field, unique=unique, filename=filename, mimetype=mimetype, - download=download, width=width, height=height, crop=crop, access_token=access_token) + download=download, width=width, height=height, crop=crop, + quality=int(kwargs.get('quality', 0)), access_token=access_token) def _content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', filename_field='datas_fname', unique=None, filename=None, mimetype=None, - download=None, width=0, height=0, crop=False, access_token=None, + download=None, width=0, height=0, crop=False, quality=0, access_token=None, placeholder='placeholder.png', **kwargs): status, headers, image_base64 = request.env['ir.http'].binary_content( xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, @@ -1083,7 +1084,7 @@ class Binary(http.Controller): if not (width or height): width, height = odoo.tools.image_guess_size_from_field_name(field) - image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop) + image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop, quality=int(quality)) content = base64.b64decode(image_base64) headers = http.set_safe_image_headers(headers, content) diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 85797ba2e23..bf169fac138 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -153,12 +153,11 @@ class Web_Editor(http.Controller): return True @http.route('/web_editor/attachment/add_data', type='json', auth='user', methods=['POST'], website=True) - def add_data(self, name, data, disable_optimization=False, res_id=False, res_model='ir.ui.view', filters=False, **kwargs): - if not disable_optimization: - try: - data = tools.image_process(data, verify_resolution=True) - except OSError: - pass # not an image + def add_data(self, name, data, quality=0, width=0, height=0, res_id=False, res_model='ir.ui.view', filters=False, **kwargs): + try: + data = tools.image_process(data, size=(width, height), quality=quality, verify_resolution=True) + except OSError: + pass # not an image attachment = self._attachment_create(name=name, data=data, res_id=res_id, res_model=res_model, filters=filters) return attachment._get_media_info() @@ -167,6 +166,22 @@ class Web_Editor(http.Controller): attachment = self._attachment_create(url=url, res_id=res_id, res_model=res_model, filters=filters) return attachment._get_media_info() + @http.route('/web_editor/attachment//update', type='json', auth='user', methods=['POST'], website=True) + def attachment_update(self, attachment, name=None, width=0, height=0, quality=0, copy=False, **kwargs): + if attachment.type == 'url': + raise UserError(_("You cannot change the quality, the width or the name of an URL attachment.")) + if copy: + attachment = attachment.copy() + data = {} + if name: + data['name'] = name + try: + data['datas'] = tools.image_process(attachment.datas, size=(width, height), quality=quality) + except OSError: + pass # not an image + attachment.write(data) + return attachment._get_media_info() + @http.route('/web_editor/attachment/remove', type='json', auth='user', website=True) def remove(self, ids, **kwargs): """ Removes a web-based image attachment if it is used by no view (template) diff --git a/addons/web_editor/models/ir_attachment.py b/addons/web_editor/models/ir_attachment.py index 83f8766b42e..ac441d821e8 100644 --- a/addons/web_editor/models/ir_attachment.py +++ b/addons/web_editor/models/ir_attachment.py @@ -3,7 +3,7 @@ from werkzeug import url_quote -from odoo import api, models, fields +from odoo import api, models, fields, tools class IrAttachment(models.Model): @@ -12,6 +12,8 @@ class IrAttachment(models.Model): local_url = fields.Char("Attachment URL", compute='_compute_local_url') image_src = fields.Char(compute='_compute_image_src') + image_width = fields.Integer(compute='_compute_image_size') + image_height = fields.Integer(compute='_compute_image_size') @api.one def _compute_local_url(self): @@ -32,8 +34,20 @@ class IrAttachment(models.Model): url_quote(attachment.name or ''), ) + @api.multi + @api.depends('datas') + def _compute_image_size(self): + for attachment in self: + try: + image = tools.base64_to_image(attachment.datas) + attachment.image_width = image.width + attachment.image_height = image.height + except Exception: + attachment.image_width = 0 + attachment.image_height = 0 + @api.multi def _get_media_info(self): """Return a dict with the values that we need on the media dialog.""" self.ensure_one() - return self.read(['id', 'name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src'])[0] + return self.read(['id', 'name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height'])[0] diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js index 9ba83486e25..8ff6a100627 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js @@ -61,7 +61,14 @@ var MediaPlugin = AbstractPlugin.extend({ media = $mediaParent[0]; $mediaParent = $mediaParent.parent(); } + + // If the image is for a field the model should handle the resize so we + // use an arbitrary high size of 1920 instead of using the width of the + // the element. + var mediaWidth = $(media).parent().data('oeField') ? 1920 : $(media).width(); + var mediaDialog = new weWidgets.MediaDialog(this.options.parent, { + mediaWidth: mediaWidth, onlyImages: $mediaParent.data('oeField') === 'image' || $mediaParent.data('oeType') === 'image', }, $(media).clone()[0] diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js new file mode 100644 index 00000000000..4ba7fbf4350 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js @@ -0,0 +1,310 @@ +odoo.define('wysiwyg.widgets.image_optimize_dialog', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); + +var _t = core._t; + +var ImageOptimizeDialog = Dialog.extend({ + template: 'wysiwyg.widgets.image.optimize', + xmlDependencies: (Dialog.prototype.xmlDependencies || []).concat([ + '/web_editor/static/src/xml/wysiwyg.xml', + ]), + events: _.extend({}, Dialog.prototype.events, { + 'click .o_we_width_preset': '_onWidthPresetClick', + 'input #o_we_height': '_onHeightInput', + 'input #o_we_name_input': '_onNameInput', + 'input #o_we_optimize_quality': '_onOptimizeQualityInput', + 'input #o_we_quality_input': '_onQualityInput', + 'input #o_we_width': '_onWidthInput', + 'input .o_we_quality_range': '_onQualityRangeInput', + }), + + /** + * @constructor + */ + init: function (parent, params, options) { + this._super(parent, _.extend({}, { + title: _t("Improve your Image"), + size: 'large', + buttons: [ + {text: _t("Optimize"), classes: 'btn-primary o_we_save', close: false, click: this._onOptimizeClick.bind(this)}, + {text: _t("Keep Original"), close: false, click: this._onKeepOriginalClick.bind(this)} + ], + }, options)); + + this.isExisting = params.isExisting; + this.attachment = params.attachment; + // We do not support resizing and quality for: + // - SVG because it doesn't make sense + // - GIF because our current code is not made to handle all the frames + this.disableResize = ['image/jpeg', 'image/jpe', 'image/jpg', 'image/png'].indexOf(this.attachment.mimetype) === -1; + this.disableQuality = this.disableResize; + this.toggleQuality = this.attachment.mimetype === 'image/png'; + this.optimizedWidth = Math.min(params.optimizedWidth || this.attachment.image_width, this.attachment.image_width); + this.defaultQuality = this.isExisting ? 100 : 80; + this.defaultWidth = parseInt(this.isExisting ? this.attachment.image_width : this.optimizedWidth); + this.defaultHeight = parseInt(this.isExisting || !this.attachment.image_width ? this.attachment.image_height : + this.optimizedWidth / this.attachment.image_width * this.attachment.image_height); + + this.suggestedWidths = []; + this._addSuggestedWidth(128, '128'); + this._addSuggestedWidth(256, '256'); + this._addSuggestedWidth(512, '512'); + this._addSuggestedWidth(1024, '1024'); + this._addSuggestedWidth(this.optimizedWidth, + _.str.sprintf(_t("%d (Suggested)"), this.optimizedWidth)); + this.suggestedWidths.push({ + 'width': this.attachment.image_width, + 'text': _.str.sprintf(_t("%d (Original)"), this.attachment.image_width), + }); + this.suggestedWidths = _.sortBy(this.suggestedWidths, 'width'); + this._updatePreview = _.debounce(this._updatePreview.bind(this), 300); + }, + /** + * @override + */ + start: function () { + var self = this; + var promise = this._super.apply(this, arguments); + this.$nameInput = this.$('#o_we_name_input'); + this.$previewImage = this.$('.o_we_preview_image'); + this.$saveButton = this.$footer.find('.o_we_save'); + + // The following selectors might not contain anything: + // - depending on disableResize + this.$widthPresets = this.$('.o_we_width_preset'); + this.$widthInput = this.$('#o_we_width'); + this.$heightInput = this.$('#o_we_height'); + // - depending on disableQuality + this.$qualityRange = this.$('.o_we_quality_range'); + this.$qualityInput = this.$('#o_we_quality_input'); + // - depending on toggleQuality + this.$qualityCheckBox = this.$('#o_we_optimize_quality'); + + this._updatePreview(); + this._validateForm(); + this.opened().then(function () { + self.$nameInput.focus(); + }); + return promise; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a size to the list of suggested width, only if it is smaller than + * the image width (we don't scale up images). + * + * @private + * @param {number} size: integer + * @param {string} text + */ + _addSuggestedWidth: function (size, text) { + if (size < this.attachment.image_width) { + this.suggestedWidths.push({ + width: size, + text: text, + }); + } + }, + /** + * Gets the current quality from the input, adapts it to match what is + * expected by image tools, and returns it. + * + * The adaptation consists of transforming the quality 100 to 0 to disable + * optimizations, and to convert values between 95 and 99 to 95 because + * values above 95 results in large files with hardly any gain in image + * quality. + * + * See `quality` parameter at: + * https://pillow.readthedocs.io/en/4.0.x/handbook/image-file-formats.html#jpeg + * + * @private + * @returns {number} the quality as integer + */ + _getAdaptedQuality: function () { + var quality = 0; + if (this.$qualityCheckBox.length) { + if (this.$qualityCheckBox.is(":checked")) { + // any value will do, as long as it is not 100 or falsy + quality = 50; + } + } else if (this.$qualityRange.length) { + quality = parseInt(this.$qualityRange.val() || 0); + } + if (quality === 100) { + quality = 0; + } + return Math.min(95, quality); + }, + /** + * Updates the preview to match the current settings, and also highlights + * the width preset buttons if applicable. + * + * @private + */ + _updatePreview: function () { + if (!this._validatePreview()) { + return; + } + var width = parseInt(this.$widthInput.val() || 0); + var height = parseInt(this.$heightInput.val() || 0); + this.$previewImage.attr('src', _.str.sprintf('/web/image/%d/%dx%d?quality=%d', + this.attachment.id, width, height, this._getAdaptedQuality())); + this.$widthPresets.removeClass('active'); + _.each(this.$widthPresets, function (button) { + var $button = $(button); + if (parseInt($button.data('width')) === width) { + $button.addClass('active'); + } + }); + }, + /** + * Validates the form and toggles the save button accordingly. + * + * @private + * @returns {boolean} whether the form is valid + */ + _validateForm: function () { + var name = this.$nameInput.val(); + var isValid = name && this._validatePreview(); + this.$saveButton.prop('disabled', !isValid); + return isValid; + }, + /** + * Validates the preview values. + * + * @private + * @returns {boolean} whether the preview values are valid + */ + _validatePreview: function () { + var isValid = true; + var quality = this._getAdaptedQuality(); + var width = parseInt(this.$widthInput.val() || 0); + var height = parseInt(this.$heightInput.val() || 0); + if (quality < 0 || quality > 100) { + isValid = false; + } + if (width < 0 || width > this.attachment.image_width) { + isValid = false; + } + if (height < 0 || height > this.attachment.image_height) { + isValid = false; + } + return isValid; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onHeightInput: function () { + var height = parseInt(this.$heightInput.val()) || 0; + this.$widthInput + .val(parseInt(height / this.attachment.image_height * this.attachment.image_width)); + this._updatePreview(); + this._validateForm(); + }, + /** + * @private + */ + _onKeepOriginalClick: function () { + this.trigger_up('attachment_updated', this.attachment); + this.close(); + }, + /** + * @private + */ + _onNameInput: function () { + this._validateForm(); + }, + /** + * Handles clicking on the save button: updates the given attachment with + * the selected settings and triggers up the `attachment_updated`` event, + * then closes the dialog. + * + * @private + * @returns {Promise} + */ + _onOptimizeClick: function () { + var self = this; + var name = this.$nameInput.val(); + var params = { + 'name': name, + 'quality': this._getAdaptedQuality(), + 'width': parseInt(this.$widthInput.val() || 0), + 'height': parseInt(this.$heightInput.val() || 0), + }; + if (this.isExisting) { + params['copy'] = true; + } + return this._rpc({ + route: _.str.sprintf('/web_editor/attachment/%d/update', this.attachment.id), + params: params, + }).then(function (attachment) { + self.trigger_up('attachment_updated', attachment); + self.close(); + }); + }, + /** + * @private + */ + _onQualityInput: function () { + var quality = parseInt(this.$qualityInput.val() || 0); + // adapt the quality to what will actually happen + if (quality === 0) { + quality = 100; + } + // prevent flickering when clearing the input to type something else + if (!quality) { + return; + } + this.$qualityRange.val(quality); + this._onQualityRangeInput(); + }, + /** + * @private + */ + _onQualityRangeInput: function () { + var quality = parseInt(this.$qualityRange.val() || 0); + this.$qualityInput.val(quality); + this._updatePreview(); + this._validateForm(); + }, + /** + * @private + */ + _onOptimizeQualityInput: function () { + this._updatePreview(); + }, + /** + * @private + */ + _onWidthInput: function () { + var width = parseInt(this.$widthInput.val() || 0); + this.$heightInput + .val(parseInt(width / this.attachment.image_width * this.attachment.image_height)); + this._updatePreview(); + this._validateForm(); + }, + /** + * @private + */ + _onWidthPresetClick: function (ev) { + ev.preventDefault(); + this.$widthInput.val(parseInt($(ev.target).data('width'))); + this._onWidthInput(); + }, +}); + +return { + ImageOptimizeDialog: ImageOptimizeDialog, +}; +}); 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 f398e23bff9..3303ec0a58f 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -1,16 +1,17 @@ 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'); var fonts = require('wysiwyg.fonts'); +var ImageOptimizeDialog = require('wysiwyg.widgets.image_optimize_dialog').ImageOptimizeDialog; var utils = require('web.utils'); var Widget = require('web.Widget'); -var concurrency = require('web.concurrency'); var QWeb = core.qweb; - var _t = core._t; var MediaWidget = Widget.extend({ @@ -104,13 +105,14 @@ var SearchableMediaWidget = MediaWidget.extend({ var FileWidget = SearchableMediaWidget.extend({ events: _.extend({}, SearchableMediaWidget.prototype.events || {}, { 'click .o_upload_media_button': '_onUploadButtonClick', - 'click .o_upload_media_button_no_optimization': '_onUploadButtonNoOptimizationClick', + 'click .o_we_quick_upload': '_onQuickUploadClick', 'change .o_file_input': '_onFileInputChange', 'click .o_upload_media_url_button': '_onUploadURLButtonClick', 'input .o_we_url_input': '_onURLInputChange', 'click .o_existing_attachment_cell': '_onAttachmentClick', 'dblclick .o_existing_attachment_cell': '_onAttachmentDblClick', 'click .o_existing_attachment_remove': '_onRemoveClick', + 'click .o_existing_attachment_optimize': '_onExistingOptimizeClick', 'click .o_load_more': '_onLoadMoreClick', }), existingAttachmentsTemplate: undefined, @@ -118,6 +120,14 @@ var FileWidget = SearchableMediaWidget.extend({ IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml'], NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30, + // This factor is used to take into account that an image displayed in a BS + // column might get bigger when displayed on a smaller breakpoint if that + // breakpoint leads to have less columns. + // Eg. col-lg-6 -> 480px per column -> col-md-12 -> 720px per column -> 1.5 + // However this will not be enough if going from 3 or more columns to 1, but + // in that case, we consider it a snippet issue. + OPTIMIZE_SIZE_FACTOR: 1.5, + /** * @constructor */ @@ -130,6 +140,7 @@ var FileWidget = SearchableMediaWidget.extend({ this.options = _.extend({ firstFilters: [], lastFilters: [], + showQuickUpload: config.debug, }, options || {}); this.attachments = []; @@ -138,6 +149,8 @@ var FileWidget = SearchableMediaWidget.extend({ this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick); }, /** + * Loads all the existing images related to the target media. + * * @override */ willStart: function () { @@ -211,7 +224,7 @@ var FileWidget = SearchableMediaWidget.extend({ args: [], kwargs: { domain: this._getAttachmentsDomain(needle), - fields: ['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src'], + fields: ['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height'], order: [{name: 'id', asc: false}], context: this.options.context, }, @@ -257,6 +270,20 @@ var FileWidget = SearchableMediaWidget.extend({ .replace(allImgClasses, ' ') .replace(allImgClassModifiers, ' '); }, + /** + * Computes and returns the width that a new attachment should have to + * ideally occupy the space where it will be inserted. + * Only relevant for images. + * + * @see options.mediaWidth + * @see OPTIMIZE_SIZE_FACTOR + * + * @private + * @returns {integer} + */ + _computeOptimizedWidth: function () { + return Math.min(1920, parseInt(this.options.mediaWidth * this.OPTIMIZE_SIZE_FACTOR)); + }, /** * Returns the domain for attachments used in media dialog. * We look for attachments related to the current document. If there is a value for the model @@ -334,6 +361,43 @@ var FileWidget = SearchableMediaWidget.extend({ return this.search(this.$('.o_we_search').val() || ''); } }, + /** + * Opens the image optimize dialog for the given attachment. + * + * Hides the media dialog while the optimize dialog is open to avoid an + * overlap of modals. + * + * @private + * @param {object} attachment + * @param {boolean} isExisting: whether this is a new attachment that was + * just uploaded, or an existing attachment + * @returns {Promise} resolved with the updated attachment object when the + * optimize dialog is saved. Rejected if the dialog is otherwise closed. + */ + _openImageOptimizeDialog: function (attachment, isExisting) { + var self = this; + var promise = new Promise(function (resolve, reject) { + self.trigger_up('hide_parent_dialog_request'); + var optimizeDialog = new ImageOptimizeDialog(self, { + attachment: attachment, + isExisting: isExisting, + optimizedWidth: self._computeOptimizedWidth(), + }).open(); + optimizeDialog.on('attachment_updated', self, function (ev) { + optimizeDialog.off('closed'); + resolve(ev.data); + }); + optimizeDialog.on('closed', self, function () { + self.noSave = true; + resolve(attachment); + }); + }); + var always = function () { + self.trigger_up('show_parent_dialog_request'); + }; + promise.then(always).guardedCatch(always); + return promise; + }, /** * Renders the existing attachments and returns the result as a string. * @@ -446,7 +510,7 @@ var FileWidget = SearchableMediaWidget.extend({ */ _selectAttachement: function (attachment, save) { if (this.options.multiImages) { - // if the clicked image is already selected then unselect it + // if the clicked attachment is already selected then unselect it // unless it was a save request (then keep the current selection) var index = this.selectedAttachments.indexOf(attachment); if (index !== -1) { @@ -454,11 +518,11 @@ var FileWidget = SearchableMediaWidget.extend({ this.selectedAttachments.splice(index, 1); } } else { - // if the clicked image is not selected, add it to selection + // if the clicked attachment is not selected, add it to selected this.selectedAttachments.push(attachment); } } else { - // select the clicked image + // select the clicked attachment this.selectedAttachments = [attachment]; } this._highlightSelected(); @@ -478,7 +542,6 @@ var FileWidget = SearchableMediaWidget.extend({ this.$addUrlButton.toggleClass('btn-secondary', emptyValue) .toggleClass('btn-primary', !emptyValue) .prop('disabled', !isURL); - this.$urlSuccess.toggleClass('d-none', !isURL); this.$urlError.toggleClass('d-none', emptyValue || isURL); }, @@ -502,6 +565,23 @@ var FileWidget = SearchableMediaWidget.extend({ this._onAttachmentClick(ev, true); }, /** + * @private + */ + _onExistingOptimizeClick: function (ev) { + var self = this; + var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell'); + var id = parseInt($a.data('id'), 10); + var attachment = _.findWhere(this.attachments, {id: id}); + ev.stopPropagation(); + return this._openImageOptimizeDialog(attachment, true).then(function (newAttachment) { + self._handleNewAttachment(newAttachment); + }); + }, + /** + * Handles change of the file input: create attachments with the new files + * and open the Preview dialog for each of them. Locks the save button until + * all new files have been processed. + * * @private * @returns {Promise} */ @@ -520,6 +600,7 @@ var FileWidget = SearchableMediaWidget.extend({ _addData: function () { var self = this; var uploadMutex = new concurrency.Mutex(); + var optimizeMutex = new concurrency.Mutex(); // Upload the smallest file first to block the user the least possible. var files = _.sortBy(this.$fileInput[0].files, 'size'); @@ -529,27 +610,55 @@ var FileWidget = SearchableMediaWidget.extend({ // limited by bandwidth. uploadMutex.exec(function () { return utils.getDataURLFromFile(file).then(function (result) { + var params = { + 'name': file.name, + 'data': result.split(',')[1], + 'res_id': self.options.res_id, + 'res_model': self.options.res_model, + 'filters': self.options.firstFilters.join('_'), + }; + if (self.quickUpload) { + params['width'] = self._computeOptimizedWidth(); + params['quality'] = 80; + } else { + params['width'] = 0; + params['quality'] = 0; + } return self._rpc({ route: '/web_editor/attachment/add_data', - params: { - 'name': file.name, - 'data': result.split(',')[1], - 'res_id': self.options.res_id, - 'res_model': self.options.res_model, - 'filters': self.options.firstFilters.join('_'), - }, + params: params, }).then(function (attachment) { - self._handleNewAttachment(attachment); + if (attachment.image_src && !self.quickUpload) { + optimizeMutex.exec(function () { + return self._openImageOptimizeDialog(attachment).then(function (updatedAttachment) { + self._handleNewAttachment(updatedAttachment); + }); + }); + } else { + self._handleNewAttachment(attachment); + } }); }); }); }); + return uploadMutex.getUnlockedDef().then(function () { - if (!self.options.multiImages) { - self.trigger_up('save_request'); - } + return optimizeMutex.getUnlockedDef().then(function () { + self.quickUpload = false; + if (!self.options.multiImages && !self.noSave) { + self.trigger_up('save_request'); + } + self.noSave = false; + }); }); }, + /** + * @private + */ + _onQuickUploadClick: function () { + this.quickUpload = true; + this.$uploadButton.trigger('click'); + }, /** * @private */ @@ -588,7 +697,7 @@ var FileWidget = SearchableMediaWidget.extend({ var emptyValue = (inputValue === ''); var isURL = /^.+\..+$/.test(inputValue); // TODO improve - var isImage = _.any(['.gif', '.jpe', '.jpg', '.png'], function (format) { + var isImage = _.any(['.gif', '.jpeg', '.jpe', '.jpg', '.png'], function (format) { return inputValue.endsWith(format); }); @@ -603,14 +712,6 @@ var FileWidget = SearchableMediaWidget.extend({ /** * @private */ - _onUploadButtonNoOptimizationClick: function () { - this.$('input[name="disable_optimization"]').val('1'); - this.$uploadButton.click(); - }, - /** - * @private - * @returns {Promise} - */ _onUploadURLButtonClick: function () { return this._mutex.exec(this._addUrl.bind(this)); }, diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js index 92c2f53099e..528cbd93bc6 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js @@ -28,6 +28,8 @@ var MediaDialog = Dialog.extend({ }), custom_events: _.extend({}, Dialog.prototype.custom_events || {}, { save_request: '_onSaveRequest', + show_parent_dialog_request: '_onShowRequest', + hide_parent_dialog_request: '_onHideRequest', }), /** @@ -209,6 +211,18 @@ var MediaDialog = Dialog.extend({ _onClickVideoTab: function () { this.activeWidget = this.videoWidget; }, + /** + * Handles hide request from child widgets. + * + * This is for usability, to allow hiding the modal for example when another + * smaller modal would be displayed on top. + * + * @private + * @param {OdooEvent} ev + */ + _onHideRequest: function (ev) { + this.$modal.addClass('d-none'); + }, /** * Handles save request from the child widgets. * @@ -222,6 +236,17 @@ var MediaDialog = Dialog.extend({ ev.stopPropagation(); this.save(); }, + /** + * Handles show request from the child widgets. + * + * This is for usability, it is the counterpart of @see _onHideRequest. + * + * @private + * @param {OdooEvent} ev + */ + _onShowRequest: function (ev) { + this.$modal.removeClass('d-none'); + }, }); return MediaDialog; diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js index 020a91b0a2b..f6eee549bfc 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js @@ -853,6 +853,7 @@ registry.background = SnippetOption.extend({ var $editable = this.$target.closest('.o_editable'); var _editor = new weWidgets.MediaDialog(this, { onlyImages: true, + mediaWidth: 1920, firstFilters: ['background'], res_model: $editable.data('oe-model'), res_id: $editable.data('oe-id'), diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss index 2cdb6ee1fca..af674a58a0b 100644 --- a/addons/web_editor/static/src/scss/wysiwyg.scss +++ b/addons/web_editor/static/src/scss/wysiwyg.scss @@ -301,25 +301,32 @@ body .modal { cursor: pointer; list-style: none; - // prevent hover/select border from flickering - border: 1px solid transparent; - - .o_existing_attachment_remove { - @include o-position-absolute(0, 0); + .o_existing_attachment_optimize, .o_existing_attachment_remove { background-color: scale-color($card-border-color, $alpha: -50%); text-shadow: 0 0 1px white; - border-radius: 0 0 0 2px; opacity: 0; cursor: pointer; &:hover { background-color: $card-border-color; + } + } + + .o_existing_attachment_optimize { + @include o-position-absolute($top: 0, $left: 0); + border-radius: 0 0 2px 0; + } + + .o_existing_attachment_remove { + @include o-position-absolute($top: 0, $right: 0); + border-radius: 0 0 0 2px; + &:hover { color: $o-wysiwyg-color-danger; } } &:hover { - .o_existing_attachment_remove { + .o_existing_attachment_optimize, .o_existing_attachment_remove { opacity: 1; } &.o_we_attachment_highlight, .o_we_attachment_highlight { @@ -455,6 +462,16 @@ body .modal { border-left: 1px solid gray('200'); } } + + .o_we_image_optimize_dialog { + .o_we_title_label { + font-size: 1rem; + } + .o_we_preview_area { + max-height: 400px; + overflow: auto; + } + } } // Highlight selected image/icon diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index a9e42a88231..abd2e940824 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -111,7 +111,6 @@
-
- @@ -186,6 +183,65 @@
+ +
+
+
+
+ + + Give a relevant name to your file to optimize search engine results. +
+ + Resizing is not supported for images of type . +
+
+
+ +
+
+ + + + + Reduce the size as much as possible to increase performance. +
+ + Changing the quality is not supported for images of type . +
+ +
+ + +
+ This reduces the quality to increase performance. +
+ + +
+
+ +
+
+ +
+
+ Reduce the quality as much as possible to increase performance. +
+
+
+
+

Preview with the current settings

+
+ Image Preview +
+
+
+
+
+
    @@ -198,6 +254,7 @@
  1. +
    diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js index 7d2a4b37bbb..0501baee4ea 100644 --- a/addons/web_editor/static/tests/test_utils.js +++ b/addons/web_editor/static/tests/test_utils.js @@ -359,6 +359,14 @@ function wysiwygData(data) { string: "image_src", type: 'char', }, + image_width: { + string: "image_width", + type: 'integer', + }, + image_height: { + string: "image_height", + type: 'integer', + }, }, records: [{ id: 1, @@ -373,6 +381,8 @@ function wysiwygData(data) { public: true, access_token: false, image_src: '/web_editor/static/src/img/transparent.png', + image_width: 256, + image_height: 256, }], generate_access_token: function () { return; diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml index 7f68d0be5d8..5b1d7e8108a 100644 --- a/addons/web_editor/views/editor.xml +++ b/addons/web_editor/views/editor.xml @@ -41,6 +41,7 @@ +