diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 603509480f0..907b86e57f0 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -173,7 +173,16 @@ class Web_Editor(http.Controller): if attachment.type == 'url': raise UserError(_("You cannot change the quality, the width or the name of an URL attachment.")) if copy: + original = attachment attachment = attachment.copy() + attachment.original_id = original + # Uniquify url by adding a path segment with the id before the name + if attachment.url: + url_fragments = attachment.url.split('/') + url_fragments.insert(-1, str(attachment.id)) + attachment.url = '/'.join(url_fragments) + elif attachment.original_id: + attachment.datas = attachment.original_id.datas data = {} if name: data['name'] = name diff --git a/addons/web_editor/models/ir_attachment.py b/addons/web_editor/models/ir_attachment.py index 7c8ff486ea6..36c44320c48 100644 --- a/addons/web_editor/models/ir_attachment.py +++ b/addons/web_editor/models/ir_attachment.py @@ -14,6 +14,7 @@ class IrAttachment(models.Model): image_src = fields.Char(compute='_compute_image_src') image_width = fields.Integer(compute='_compute_image_size') image_height = fields.Integer(compute='_compute_image_size') + original_id = fields.Many2one('ir.attachment', string="Original (unoptimized, unresized) attachment") def _compute_local_url(self): for attachment in self: @@ -47,4 +48,4 @@ class IrAttachment(models.Model): 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', 'image_width', 'image_height'])[0] + return self._read_format(['id', 'name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'])[0] diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js index 91be867260a..d2f29e53f7f 100644 --- a/addons/web_editor/static/src/js/editor/editor.js +++ b/addons/web_editor/static/src/js/editor/editor.js @@ -171,7 +171,6 @@ var EditorMenuBar = Widget.extend({ if (this.snippetsMenu) { await this.snippetsMenu.cleanForSave(); } - await this.getParent().saveCroppedImages(this.rte.editable()); await this.rte.save(); 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 index ef6222f223d..1c655026f32 100644 --- 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 @@ -33,20 +33,37 @@ var ImageOptimizeDialog = Dialog.extend({ {text: _t("Keep Original"), close: false, click: this._onKeepOriginalClick.bind(this)} ], }, options)); - - this.isExisting = params.isExisting; - this.attachment = params.attachment; + this.params = params; + }, + /** + * @override + */ + willStart: async function () { + const _super = this._super.bind(this); + const {isExisting, attachment, optimizedWidth} = this.params; + this.isExisting = isExisting; + this.attachment = attachment; + let original = attachment; + if (attachment.original_id) { + [original] = await this._rpc({ + model: 'ir.attachment', + method: 'read', + args: [[attachment.original_id[0]], ['image_width', 'image_height']], + }); + } + this.image_width = original.image_width; + this.image_height = original.image_height; // 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.optimizedWidth = Math.min(optimizedWidth || this.image_width, this.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.defaultWidth = parseInt(this.isExisting ? this.image_width : this.optimizedWidth); + this.defaultHeight = parseInt(this.isExisting || !this.image_width ? this.image_height : + this.optimizedWidth / this.image_width * this.image_height); this.suggestedWidths = []; this._addSuggestedWidth(128, '128'); @@ -56,11 +73,12 @@ var ImageOptimizeDialog = Dialog.extend({ 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), + 'width': this.image_width, + 'text': _.str.sprintf(_t("%d (Original)"), this.image_width), }); this.suggestedWidths = _.sortBy(this.suggestedWidths, 'width'); this._updatePreview = _.debounce(this._updatePreview.bind(this), 300); + return _super(...arguments); }, /** * @override @@ -104,7 +122,7 @@ var ImageOptimizeDialog = Dialog.extend({ * @param {string} text */ _addSuggestedWidth: function (size, text) { - if (size < this.attachment.image_width) { + if (size < this.image_width) { this.suggestedWidths.push({ width: size, text: text, @@ -154,7 +172,7 @@ var ImageOptimizeDialog = Dialog.extend({ 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.attachment.original_id || [this.attachment.id])[0], width, height, this._getAdaptedQuality())); this.$widthPresets.removeClass('active'); _.each(this.$widthPresets, function (button) { var $button = $(button); @@ -189,10 +207,10 @@ var ImageOptimizeDialog = Dialog.extend({ if (quality < 0 || quality > 100) { isValid = false; } - if (width < 0 || width > this.attachment.image_width) { + if (width < 0 || width > this.image_width) { isValid = false; } - if (height < 0 || height > this.attachment.image_height) { + if (height < 0 || height > this.image_height) { isValid = false; } return isValid; @@ -208,7 +226,7 @@ var ImageOptimizeDialog = Dialog.extend({ _onHeightInput: function () { var height = parseInt(this.$heightInput.val()) || 0; this.$widthInput - .val(parseInt(height / this.attachment.image_height * this.attachment.image_width)); + .val(parseInt(height / this.image_height * this.image_width)); this._updatePreview(); this._validateForm(); }, @@ -244,7 +262,7 @@ var ImageOptimizeDialog = Dialog.extend({ 'width': parseInt(this.$widthInput.val() || 0), 'height': parseInt(this.$heightInput.val() || 0), }; - if (this.isExisting) { + if (this.isExisting && !this.attachment.original_id) { params['copy'] = true; } return this._rpc({ @@ -292,7 +310,7 @@ var ImageOptimizeDialog = Dialog.extend({ _onWidthInput: function () { var width = parseInt(this.$widthInput.val() || 0); this.$heightInput - .val(parseInt(width / this.attachment.image_width * this.attachment.image_height)); + .val(parseInt(width / this.image_width * this.image_height)); this._updatePreview(); this._validateForm(); }, 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 65dc4aab355..87fac3cd640 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -118,7 +118,6 @@ var SearchableMediaWidget = MediaWidget.extend({ var FileWidget = SearchableMediaWidget.extend({ events: _.extend({}, SearchableMediaWidget.prototype.events || {}, { 'click .o_upload_media_button': '_onUploadButtonClick', - 'click .o_we_quick_upload': '_onQuickUploadClick', 'change .o_file_input': '_onFileInputChange', 'click .o_upload_media_url_button': '_onUploadURLButtonClick', 'input .o_we_url_input': '_onURLInputChange', @@ -150,7 +149,6 @@ var FileWidget = SearchableMediaWidget.extend({ this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY; this.options = _.extend({ - showQuickUpload: config.isDebug(), mediaWidth: media && media.clientWidth, }, options || {}); @@ -192,7 +190,7 @@ var FileWidget = SearchableMediaWidget.extend({ await this._renderThumbnails(); if (o.url) { self._selectAttachement(_.find(self.attachments, function (attachment) { - return attachment.image_src === o.url; + return o.url === attachment.image_src; }) || o); } return def; @@ -231,7 +229,7 @@ var FileWidget = SearchableMediaWidget.extend({ args: [], kwargs: { domain: this._getAttachmentsDomain(this.needle), - fields: ['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height'], + fields: ['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'], order: [{name: 'id', asc: false}], context: this.options.context, // Try to fetch first record of next page just to know whether there is a next page. @@ -328,7 +326,8 @@ var FileWidget = SearchableMediaWidget.extend({ var self = this; this.$('.o_existing_attachment_cell.o_we_attachment_selected').removeClass("o_we_attachment_selected"); _.each(this.selectedAttachments, function (attachment) { - self.$('.o_existing_attachment_cell[data-id=' + attachment.id + ']').addClass("o_we_attachment_selected"); + self.$('.o_existing_attachment_cell[data-id=' + attachment.id + ']') + .addClass("o_we_attachment_selected").css('display', ''); }); }, /** @@ -336,6 +335,7 @@ var FileWidget = SearchableMediaWidget.extend({ * @param {object} attachment */ _handleNewAttachment: function (attachment) { + this.attachments = this.attachments.filter(att => att.id !== attachment.id); this.attachments.unshift(attachment); this._renderThumbnails(); this._selectAttachement(attachment); @@ -368,7 +368,7 @@ var FileWidget = SearchableMediaWidget.extend({ * @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) { + _openImageOptimizeDialog: function (attachment, isExisting, $attachmentCell) { var self = this; var promise = new Promise(function (resolve, reject) { self.trigger_up('hide_parent_dialog_request'); @@ -390,7 +390,7 @@ var FileWidget = SearchableMediaWidget.extend({ } }); }); - var always = function () { + var always = () => { self.trigger_up('show_parent_dialog_request'); }; promise.then(always).guardedCatch(always); @@ -431,21 +431,23 @@ var FileWidget = SearchableMediaWidget.extend({ * @private * @returns {Promise} */ - _save: function () { - var self = this; - + _save: async function () { if (this.options.multiImages) { - return Promise.resolve(this.selectedAttachments); + return this.selectedAttachments; } var img = this.selectedAttachments[0]; if (!img || !img.id) { - return Promise.resolve(this.media); + return this.media; + } + + // Auto optimize unoptimized images. + if (['image/jpeg', 'image/jpe', 'image/jpg', 'image/png'].includes(img.mimetype) && img.type === 'binary' && !img.original_id) { + img = await this._optimizeAttachment(img); } - var prom; if (!img.public && !img.access_token) { - prom = this._rpc({ + await this._rpc({ model: 'ir.attachment', method: 'generate_access_token', args: [[img.id]] @@ -454,52 +456,67 @@ var FileWidget = SearchableMediaWidget.extend({ }); } - return Promise.resolve(prom).then(function () { - if (img.image_src) { - var src = img.image_src; - if (!img.public && img.access_token) { - src += _.str.sprintf('?access_token=%s', img.access_token); - } - if (!self.$media.is('img')) { - - // Note: by default the images receive the bootstrap opt-in - // img-fluid class. We cannot make them all responsive - // by design because of libraries and client databases img. - self.$media = $('', {class: 'img-fluid o_we_custom_image'}); - self.media = self.$media[0]; - } - self.$media.attr('src', src); - } else { - if (!self.$media.is('a')) { - $('.note-control-selection').hide(); - self.$media = $(''); - self.media = self.$media[0]; - } - var href = '/web/content/' + img.id + '?'; - if (!img.public && img.access_token) { - href += _.str.sprintf('access_token=%s&', img.access_token); - } - href += 'unique=' + img.checksum + '&download=true'; - self.$media.attr('href', href); - self.$media.addClass('o_image').attr('title', img.name).attr('data-mimetype', img.mimetype); + if (img.image_src) { + var src = img.image_src; + if (!img.public && img.access_token) { + src += _.str.sprintf('?access_token=%s', img.access_token); } + if (!this.$media.is('img')) { - self.$media.attr('alt', img.alt); - var style = self.style; - if (style) { - self.$media.css(style); + // Note: by default the images receive the bootstrap opt-in + // img-fluid class. We cannot make them all responsive + // by design because of libraries and client databases img. + this.$media = $('', {class: 'img-fluid o_we_custom_image'}); + this.media = this.$media[0]; } + this.$media.attr('src', src); + } else { + if (!this.$media.is('a')) { + $('.note-control-selection').hide(); + this.$media = $(''); + this.media = this.$media[0]; + } + var href = '/web/content/' + img.id + '?'; + if (!img.public && img.access_token) { + href += _.str.sprintf('access_token=%s&', img.access_token); + } + href += 'unique=' + img.checksum + '&download=true'; + this.$media.attr('href', href); + this.$media.addClass('o_image').attr('title', img.name).attr('data-mimetype', img.mimetype); + } - // Remove crop related attributes - if (self.$media.attr('data-aspect-ratio')) { - var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y', 'crop:originalSrc']; - self.$media.removeClass('o_cropped_img_to_save'); - _.each(attrs, function (attr) { - self.$media.removeData(attr); - self.$media.removeAttr('data-' + attr); - }); - } - return self.media; + this.$media.attr('alt', img.alt); + var style = this.style; + if (style) { + this.$media.css(style); + } + + // Remove crop related attributes + if (this.$media.attr('data-aspect-ratio')) { + var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y', 'crop:originalSrc']; + this.$media.removeClass('o_cropped_img_to_save'); + attrs.forEach(attr => { + this.$media.removeData(attr); + this.$media.removeAttr('data-' + attr); + }); + } + return this.media; + }, + /** + * Creates and returns an optimized copy of an attachment. + * + * @private + * @param {object} attachment + */ + _optimizeAttachment: function (attachment) { + return this._rpc({ + route: `/web_editor/attachment/${attachment.id}/update`, + params: { + copy: true, + name: attachment.name, + quality: attachment.mimetype === 'image/png' ? 0 : 80, + width: this._computeOptimizedWidth(), + }, }); }, /** @@ -562,13 +579,12 @@ var FileWidget = SearchableMediaWidget.extend({ * @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); + return this._openImageOptimizeDialog(attachment, true, $a).then(newAttachment => { + this._handleNewAttachment(newAttachment); }); }, /** @@ -594,7 +610,6 @@ 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'); @@ -604,54 +619,30 @@ 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, - }; - 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: params, + params: { + 'name': file.name, + 'data': result.split(',')[1], + 'res_id': self.options.res_id, + 'res_model': self.options.res_model, + 'width': 0, + 'quality': 0, + }, }).then(function (attachment) { - if (attachment.image_src && !self.quickUpload) { - optimizeMutex.exec(function () { - return self._openImageOptimizeDialog(attachment).then(function (updatedAttachment) { - self._handleNewAttachment(updatedAttachment); - }); - }); - } else { - self._handleNewAttachment(attachment); - } + self._handleNewAttachment(attachment); }); }); }); }); return uploadMutex.getUnlockedDef().then(function () { - return optimizeMutex.getUnlockedDef().then(function () { - self.quickUpload = false; - if (!self.options.multiImages && !self.noSave) { - self.trigger_up('save_request'); - } - self.noSave = 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 */ @@ -671,6 +662,7 @@ var FileWidget = SearchableMediaWidget.extend({ }).then(function (prevented) { if (_.isEmpty(prevented)) { self.attachments = _.without(self.attachments, attachment); + self.attachments.filter(at => at.original_id[0] === attachment.id).forEach(at => delete at.original_id); if (!self.attachments.length) { self._renderThumbnails(); //render the message and image if empty } else { @@ -760,6 +752,9 @@ var FileWidget = SearchableMediaWidget.extend({ var ImageWidget = FileWidget.extend({ template: 'wysiwyg.widgets.image', existingAttachmentsTemplate: 'wysiwyg.widgets.image.existing.attachments', + events: Object.assign({}, FileWidget.prototype.events, { + 'change input.o_we_show_optimized': '_onShowOptimizedChange', + }), MIN_ROW_HEIGHT: 128, /** @@ -820,6 +815,7 @@ var ImageWidget = FileWidget.extend({ toReplace.replaceWith(el); } }); + this._toggleOptimized(this.$('input.o_we_show_optimized')[0].checked); // Placeholders have a 3:2 aspect ratio like most photos. const placeholderWidth = 3 / 2 * this.MIN_ROW_HEIGHT; this.$('.o_we_attachment_placeholder').css({ @@ -827,6 +823,23 @@ var ImageWidget = FileWidget.extend({ flexBasis: placeholderWidth, }); }, + /** + * @override + */ + _renderExisting: function (attachments) { + return QWeb.render(this.existingAttachmentsTemplate, { + attachments: attachments, + widget: this, + }); + }, + /** + * @private + * + * @param {boolean} value whether to toggle optimized attachments on or off + */ + _toggleOptimized: function (value) { + this.$('.o_we_attachment_optimized').each((i, cell) => cell.style.setProperty('display', value ? null : 'none', 'important')); + }, //-------------------------------------------------------------------------- // Handlers @@ -849,6 +862,12 @@ var ImageWidget = FileWidget.extend({ cell.classList.add('d-flex'); cell.dataset.loaded = 'true'; }, + /** + * @override + */ + _onShowOptimizedChange: function (ev) { + this._toggleOptimized(ev.target.checked); + }, }); diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss index 2c6e029f97a..7091f7c04cf 100644 --- a/addons/web_editor/static/src/scss/wysiwyg.scss +++ b/addons/web_editor/static/src/scss/wysiwyg.scss @@ -269,6 +269,13 @@ body .modal { box-shadow: 0px 0px 2px 2px $o-brand-primary; } + .o_we_attachment_optimized .badge { + position: absolute; + bottom: 0; + right: 0; + margin: 2px; + } + .font-icons-icons { > span { text-align: center; diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index 6b07a92dfcd..677128fb750 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -142,7 +142,6 @@ -
@@ -158,7 +157,7 @@
- + @@ -170,12 +169,14 @@ Add URL Search an image The URL does not contain any image. The file will be added in the document section. - - - +
+
+ + +
+
@@ -215,7 +216,7 @@
Width
- +
px
@@ -229,7 +230,7 @@
Height
- +
px
@@ -304,13 +305,15 @@ -
+ +
+ Optimized
diff --git a/addons/web_editor/static/tests/field_html_tests.js b/addons/web_editor/static/tests/field_html_tests.js index e72b836d83c..7e49f7fce01 100644 --- a/addons/web_editor/static/tests/field_html_tests.js +++ b/addons/web_editor/static/tests/field_html_tests.js @@ -284,9 +284,7 @@ QUnit.module('web_editor', {}, function () { await testUtils.dom.click($('.modal #editor-media-image .o_existing_attachment_cell:first').removeClass('d-none')); var $editable = form.$('.oe_form_field[name="body"] .note-editable'); - - assert.strictEqual($editable.data('wysiwyg').getValue(), - '

toto toto toto

tata

', + assert.ok($editable.find('img')[0].dataset.src.includes('/web_editor/static/src/img/transparent.png'), "should have the image in the dom"); testUtils.mock.unpatch(MediaDialog); diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js index 74a2ba1f3d8..fcbf52f2521 100644 --- a/addons/web_editor/static/tests/test_utils.js +++ b/addons/web_editor/static/tests/test_utils.js @@ -259,6 +259,11 @@ function wysiwygData(data) { string: "image_height", type: 'integer', }, + original_id: { + string: "original_id", + type: 'many2one', + relation: 'ir.attachment', + }, }, records: [{ id: 1,