From 76cf2ae82a45b9ffcf63b524944a42b7f5192b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Theys?= Date: Tue, 4 Jun 2019 15:11:08 +0200 Subject: [PATCH] [IMP] web_editor, *: improve media dialog attachment upload * = web_unsplash, website Remove iframe upload and replace it with a proper RPC. The route is now only accepting one file at a time, which makes it easier to maintain, and allows to process the first file as soon as it is ready, instead of having to wait on everything. Also fix a bug where a document could not be uploaded because it would try to parse it as an image. Remove filter after search result from the JS and add the corresponding conditions in the domain to avoid processing and returning unnecessary records. If multiple attachments exist with the same URL, now display them all to avoid confusion. Compute all the necessary values in the python model instead of in the JS. Allow SVG in the widget (the server already accepted them before). Disable multiple image upload when not in multiImages mode. task-1930726 PR: #31208 --- addons/web_editor/controllers/main.py | 132 ++++------ addons/web_editor/models/ir_attachment.py | 21 ++ .../static/src/js/wysiwyg/plugin/dropzone.js | 10 +- .../static/src/js/wysiwyg/widgets/media.js | 240 +++++++++--------- addons/web_editor/static/src/xml/wysiwyg.xml | 15 +- addons/web_editor/static/tests/test_utils.js | 13 +- .../web_editor/static/tests/wysiwyg_tests.js | 37 ++- addons/web_unsplash/controllers/main.py | 4 +- .../static/src/js/unsplash_image_widget.js | 4 - .../static/src/js/editor/snippets.options.js | 2 +- odoo/fields.py | 2 +- odoo/tools/image.py | 4 +- 12 files changed, 232 insertions(+), 252 deletions(-) diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 41bb7fceb54..85797ba2e23 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import base64 import io -import json import logging import re import time @@ -11,7 +9,8 @@ from PIL import Image, ImageFont, ImageDraw from lxml import etree from odoo.http import request -from odoo import http, tools +from odoo import http, tools, _ +from odoo.exceptions import UserError logger = logging.getLogger(__name__) @@ -153,86 +152,21 @@ class Web_Editor(http.Controller): return True - #------------------------------------------------------ - # upload an image as base64 - #------------------------------------------------------ - @http.route('/web_editor/add_image_base64', type='json', auth='user', methods=['POST'], website=True) - def add_image_base64(self, res_model, res_id, image_base64, filename, disable_optimization=None, **kwargs): - attachment = self._image_to_attachment(res_model, res_id, image_base64, filename, filename, disable_optimization=disable_optimization) - return attachment.read(['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'access_token'])[0] - - def _image_to_attachment(self, res_model, res_id, image_base64, name, datas_fname, disable_optimization=None): - Attachments = request.env['ir.attachment'] + @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: - image_base64 = tools.image_process(image_base64, verify_resolution=True) - attachment = Attachments.create({ - 'name': name, - 'datas_fname': datas_fname, - 'datas': image_base64, - 'public': res_model == 'ir.ui.view', - 'res_id': res_id, - 'res_model': res_model, - }) - attachment.generate_access_token() - return attachment - - #------------------------------------------------------ - # add attachment (images or link) - #------------------------------------------------------ - @http.route('/web_editor/attachment/add', type='http', auth='user', methods=['POST'], website=True) - def attach(self, upload=None, url=None, disable_optimization=None, filters=None, **kwargs): - # the upload argument doesn't allow us to access the files if more than - # one file is uploaded, as upload references the first file - # therefore we have to recover the files from the request object - Attachments = request.env['ir.attachment'] # registry for the attachment table - - res_model = kwargs.get('res_model', 'ir.ui.view') - if res_model != 'ir.ui.view' and kwargs.get('res_id'): - res_id = int(kwargs['res_id']) - else: - res_id = None - - uploads = [] - message = None - if not upload: # no image provided, storing the link and the image name - name = url.split("/").pop() # recover filename - datas_fname = name - if filters: - datas_fname = filters + '_' + datas_fname - attachment = Attachments.create({ - 'name': name, - 'datas_fname': datas_fname, - 'type': 'url', - 'url': url, - 'public': res_model == 'ir.ui.view', - 'res_id': res_id, - 'res_model': res_model, - }) - attachment.generate_access_token() - uploads += attachment.read(['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'access_token']) - else: # images provided try: - attachments = request.env['ir.attachment'] - for c_file in request.httprequest.files.getlist('upload'): - image_base64 = base64.b64encode(c_file.read()) - name = c_file.filename - datas_fname = name - if filters: - datas_fname = filters + '_' + datas_fname - attachments += self._image_to_attachment(res_model, res_id, image_base64, name, datas_fname, disable_optimization=disable_optimization) - uploads += attachments.read(['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'access_token']) - except Exception as e: - logger.exception("Failed to upload image to attachment") - message = str(e) + data = tools.image_process(data, 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() - return """""" % (json.dumps(uploads), json.dumps(message)) + @http.route('/web_editor/attachment/add_url', type='json', auth='user', methods=['POST'], website=True) + def add_url(self, url, res_id=False, res_model='ir.ui.view', filters=False, **kwargs): + attachment = self._attachment_create(url=url, res_id=res_id, res_model=res_model, filters=filters) + return attachment._get_media_info() - #------------------------------------------------------ - # remove attachment (images or link) - #------------------------------------------------------ @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) @@ -264,11 +198,12 @@ class Web_Editor(http.Controller): attachments_to_remove.unlink() return removal_blocked_by - ## This route is used from CropImageDialog to get image info. - ## It is used to display the original image when we crop a previously - ## cropped image @http.route('/web_editor/get_image_info', type='json', auth='user', website=True) def get_image_info(self, image_id=None, xml_id=None): + """This route is used from CropImageDialog to get image info. + It is used to display the original image when we crop a previously + cropped image. + """ if xml_id: record = request.env['ir.attachment'].get_attachment_by_key(xml_id) elif image_id: @@ -284,6 +219,39 @@ class Web_Editor(http.Controller): result['originalSrc'] = record.url return result + def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view', filters=None): + """Create and return a new attachment.""" + if not name and url: + name = url.split("/").pop() + + if res_model != 'ir.ui.view' and res_id: + res_id = int(res_id) + else: + res_id = False + + if filters: + name = filters + '_' + name + + attachment_data = { + 'name': name, + 'public': res_model == 'ir.ui.view', + 'res_id': res_id, + 'res_model': res_model, + } + + if data: + attachment_data['datas'] = data + elif url: + attachment_data.update({ + 'type': 'url', + 'url': url, + }) + else: + raise UserError(_("You need to specify either data or url to create an attachment.")) + + attachment = request.env['ir.attachment'].create(attachment_data) + return attachment + @http.route("/web_editor/get_assets_editor_resources", type="json", auth="user", website=True) def get_assets_editor_resources(self, key, get_views=True, get_scss=True, get_js=True, bundles=False, bundles_restriction=[], only_user_custom_files=True): """ diff --git a/addons/web_editor/models/ir_attachment.py b/addons/web_editor/models/ir_attachment.py index 0c5f0e53de8..83f8766b42e 100644 --- a/addons/web_editor/models/ir_attachment.py +++ b/addons/web_editor/models/ir_attachment.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from werkzeug import url_quote + from odoo import api, models, fields @@ -9,6 +11,7 @@ class IrAttachment(models.Model): _inherit = "ir.attachment" local_url = fields.Char("Attachment URL", compute='_compute_local_url') + image_src = fields.Char(compute='_compute_image_src') @api.one def _compute_local_url(self): @@ -16,3 +19,21 @@ class IrAttachment(models.Model): self.local_url = self.url else: self.local_url = '/web/image/%s?unique=%s' % (self.id, self.checksum) + + @api.multi + @api.depends('mimetype', 'url', 'name') + def _compute_image_src(self): + for attachment in self: + if attachment.mimetype not in ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml']: + attachment.image_src = False + else: + attachment.image_src = attachment.url or '/web/image/%s/%s' % ( + attachment.id, + url_quote(attachment.name or ''), + ) + + @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] diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js b/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js index 3561f5b071c..a88fc50fc7f 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js @@ -148,12 +148,12 @@ var DropzonePlugin = Plugins.dropzone.extend({ }); return this._rpc({ - route: '/web_editor/add_image_base64', + route: '/web_editor/attachment/add_data', params: { - res_model: options.res_model, - res_id: options.res_id || 0, - image_base64: imageBase64.split(';base64,').pop(), - filename: fileName, + 'data': imageBase64.split(';base64,').pop(), + 'name': fileName, + 'res_id': options.res_id || 0, + 'res_model': options.res_model, }, }); }, 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 8e923acc6db..f398e23bff9 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -3,7 +3,9 @@ odoo.define('wysiwyg.widgets.media', function (require) { var core = require('web.core'); var Dialog = require('web.Dialog'); +var dom = require('web.dom'); var fonts = require('wysiwyg.fonts'); +var utils = require('web.utils'); var Widget = require('web.Widget'); var concurrency = require('web.concurrency'); @@ -113,7 +115,7 @@ var FileWidget = SearchableMediaWidget.extend({ }), existingAttachmentsTemplate: undefined, - IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png'], + IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml'], NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30, /** @@ -132,6 +134,8 @@ var FileWidget = SearchableMediaWidget.extend({ this.attachments = []; this.selectedAttachments = []; + + this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick); }, /** * @override @@ -173,9 +177,9 @@ var FileWidget = SearchableMediaWidget.extend({ o.id = +o.url.match(/\/web\/content\/(\d+)/, '')[1]; } if (o.url) { - self._toggleImage(_.find(self.attachments, function (attachment) { + self._selectAttachement(_.find(self.attachments, function (attachment) { return attachment.url === o.url; - }) || o, true); + }) || o); } return def; @@ -207,40 +211,28 @@ var FileWidget = SearchableMediaWidget.extend({ args: [], kwargs: { domain: this._getAttachmentsDomain(needle), - fields: ['name', 'datas_fname', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'access_token'], + fields: ['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src'], order: [{name: 'id', asc: false}], context: this.options.context, }, }).then(function (attachments) { self.attachments = _.chain(attachments) - .filter(function (r) { - return (r.type === "binary" || r.url && r.url.length > 0); - }) - .uniq(function (r) { - return (r.url || r.id); - }) .sortBy(function (r) { if (_.any(self.options.firstFilters, function (filter) { var regex = new RegExp(filter, 'i'); - return r.name.match(regex) || r.datas_fname && r.datas_fname.match(regex); + return r.name.match(regex) || r.name && r.name.match(regex); })) { return -1; } if (_.any(self.options.lastFilters, function (filter) { var regex = new RegExp(filter, 'i'); - return r.name.match(regex) || r.datas_fname && r.datas_fname.match(regex); + return r.name.match(regex) || r.name && r.name.match(regex); })) { return 1; } return 0; }) .value(); - - _.each(self.attachments, function (attachment) { - // Name is added for SEO purposes - attachment.src = attachment.url || _.str.sprintf('/web/image/%s/%s', attachment.id, encodeURI(attachment.name)); - attachment.isDocument = !(/gif|jpe|jpg|png/.test(attachment.mimetype)); - }); if (!noRender) { self._renderImages(); } @@ -304,9 +296,10 @@ var FileWidget = SearchableMediaWidget.extend({ domain = ['|', ['public', '=', true]].concat(domain); domain = domain.concat(this.options.mimetypeDomain); if (needle && needle.length) { - domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]); + domain.push('|', ['name', 'ilike', needle], ['name', 'ilike', needle]); } - domain.push('|', ['datas_fname', '=', false], '!', ['datas_fname', '=like', '%.crop'], '!', ['name', '=like', '%.crop']); + domain.push('|', ['name', '=', false], '!', ['name', '=like', '%.crop'], '!', ['name', '=like', '%.crop']); + domain.push('|', ['type', '=', 'binary'], ['url', '!=', false]); return domain; }, /** @@ -321,13 +314,24 @@ var FileWidget = SearchableMediaWidget.extend({ }, /** * @private + * @param {object} attachment + */ + _handleNewAttachment: function (attachment) { + this.attachments.unshift(attachment); + this._renderImages(); + this._selectAttachement(attachment); + }, + /** + * @private + * @returns {Promise} */ _loadMoreImages: function (forceSearch) { this.numberOfAttachmentsToDisplay += 10; if (!forceSearch) { this._renderImages(); + return Promise.resolve(); } else { - this.search(this.$('.o_we_search').val() || ''); + return this.search(this.$('.o_we_search').val() || ''); } }, /** @@ -348,8 +352,6 @@ var FileWidget = SearchableMediaWidget.extend({ _renderImages: function () { var attachments = this.attachments.slice(0, this.numberOfAttachmentsToDisplay); - this.$errorText.empty(); - // Render menu & content this.$('.existing-attachments').replaceWith( this._renderExisting(attachments) @@ -374,12 +376,12 @@ var FileWidget = SearchableMediaWidget.extend({ } var img = this.selectedAttachments[0]; - if (!img) { + if (!img || !img.id) { return Promise.resolve(this.media); } var prom; - if (!img.access_token) { + if (!img.public && !img.access_token) { prom = this._rpc({ model: 'ir.attachment', method: 'generate_access_token', @@ -390,19 +392,20 @@ var FileWidget = SearchableMediaWidget.extend({ } return Promise.resolve(prom).then(function () { - if (!img.isDocument) { - if (img.access_token && self.options.res_model !== 'ir.ui.view') { - img.src += _.str.sprintf('?access_token=%s', img.access_token); + 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', img.src); - + self.$media.attr('src', src); } else { if (!self.$media.is('a')) { $('.note-control-selection').hide(); @@ -410,7 +413,7 @@ var FileWidget = SearchableMediaWidget.extend({ self.media = self.$media[0]; } var href = '/web/content/' + img.id + '?'; - if (img.access_token && self.options.res_model !== 'ir.ui.view') { + if (!img.public && img.access_token) { href += _.str.sprintf('access_token=%s&', img.access_token); } href += 'unique=' + img.checksum + '&download=true'; @@ -436,19 +439,22 @@ var FileWidget = SearchableMediaWidget.extend({ }); }, /** + * @param {object} attachment + * @param {boolean} [save=true] to save the given attachment in the DOM and + * and to close the media dialog * @private */ - _toggleImage: function (attachment, doubleClick) { + _selectAttachement: function (attachment, save) { if (this.options.multiImages) { - // if the clicked image is already selected, then unselect it - // unless it was a double click + // if the clicked image 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) { - if (!doubleClick) { + if (!save) { this.selectedAttachments.splice(index, 1); } } else { - // if the clicked image is not selected, then select it + // if the clicked image is not selected, add it to selection this.selectedAttachments.push(attachment); } } else { @@ -456,6 +462,9 @@ var FileWidget = SearchableMediaWidget.extend({ this.selectedAttachments = [attachment]; } this._highlightSelected(); + if (save) { + this.trigger_up('save_request'); + } }, /** * Updates the add by URL UI. @@ -473,73 +482,6 @@ var FileWidget = SearchableMediaWidget.extend({ this.$urlSuccess.toggleClass('d-none', !isURL); this.$urlError.toggleClass('d-none', emptyValue || isURL); }, - /** - * @private - */ - _uploadFile: function () { - return this._mutex.exec(this._uploadImageIframe.bind(this)); - }, - /** - * @returns {Promise} - */ - _uploadImageIframe: function () { - var self = this; - return new Promise(function (resolve) { - - /** - * @todo file upload cannot be handled with _rpc smoothly. This uses the - * form posting in iframe trick to handle the upload. - */ - var $iframe = self.$('iframe'); - $iframe.on('load', function () { - var iWindow = $iframe[0].contentWindow; - - var attachments = iWindow.attachments || []; - var error = iWindow.error; - - self.$('.well > span').remove(); - self.$('.well > div').show(); - _.each(attachments, function (attachment) { - // Name is added for SEO purposes - attachment.src = attachment.url || _.str.sprintf('/web/image/%s/%s', attachment.id, encodeURI(attachment.name)); - attachment.isDocument = !(/gif|jpe|jpg|png/.test(attachment.mimetype)); - }); - if (error || !attachments.length) { - _processFile(null, error || !attachments.length); - } - self.attachments = attachments; - for (var i = 0 ; i < attachments.length ; i++) { - _processFile(attachments[i], error); - } - - if (self.options.onUpload) { - self.options.onUpload(attachments); - } - - resolve(); - - function _processFile(attachment, error) { - var $button = self.$uploadButton; - if (!error) { - $button.addClass('btn-success'); - self._toggleImage(attachment, true); - } else { - $button.addClass('btn-danger'); - self.$el.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid'); - self.$errorText.text(error); - } - - if (!self.options.multiImages) { - self.trigger_up('save_request'); - } - } - }); - self.$el.submit(); - - self.$fileInput.val(''); - }); - }, - //-------------------------------------------------------------------------- // Handlers @@ -548,36 +490,74 @@ var FileWidget = SearchableMediaWidget.extend({ /** * @private */ - _onAttachmentClick: function (ev, doubleClick) { + _onAttachmentClick: function (ev, save) { var $attachment = $(ev.currentTarget); var attachment = _.find(this.attachments, {id: $attachment.data('id')}); - this._toggleImage(attachment, doubleClick); + this._selectAttachement(attachment, save); }, /** * @private */ _onAttachmentDblClick: function (ev) { this._onAttachmentClick(ev, true); - this.trigger_up('save_request'); }, /** * @private + * @returns {Promise} */ _onFileInputChange: function () { - this.$el.addClass('nosave'); - this.$form.removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid'); - this.$errorText.empty(); - this.$uploadButton.removeClass('btn-danger btn-success'); - this._uploadFile(); + return this._mutex.exec(this._addData.bind(this)); + }, + /** + * Uploads the files that are currently selected on the file input, which + * creates new attachments. Then inserts them on the media dialog and + * selects them. If multiImages is not set, also triggers up the + * save_request event to insert the attachment in the DOM. + * + * @private + * @returns {Promise} + */ + _addData: function () { + var self = this; + var uploadMutex = new concurrency.Mutex(); + + // Upload the smallest file first to block the user the least possible. + var files = _.sortBy(this.$fileInput[0].files, 'size'); + + _.each(files, function (file) { + // Upload one file at a time: no need to parallel as upload is + // limited by bandwidth. + uploadMutex.exec(function () { + return utils.getDataURLFromFile(file).then(function (result) { + 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('_'), + }, + }).then(function (attachment) { + self._handleNewAttachment(attachment); + }); + }); + }); + }); + return uploadMutex.getUnlockedDef().then(function () { + if (!self.options.multiImages) { + self.trigger_up('save_request'); + } + }); }, /** * @private */ _onRemoveClick: function (ev) { var self = this; + ev.stopPropagation(); Dialog.confirm(this, _t("Are you sure you want to delete this file ?"), { confirm_callback: function () { - var $helpBlock = self.$errorText.empty(); var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell'); var id = parseInt($a.data('id'), 10); var attachment = _.findWhere(self.attachments, {id: id}); @@ -589,10 +569,10 @@ var FileWidget = SearchableMediaWidget.extend({ }).then(function (prevented) { if (_.isEmpty(prevented)) { self.attachments = _.without(self.attachments, attachment); - self._renderImages(); + $a.closest('.o_existing_attachment_cell').remove(); return; } - $helpBlock.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', { + self.$errorText.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', { views: prevented[id], widget: self, })); @@ -603,10 +583,8 @@ var FileWidget = SearchableMediaWidget.extend({ /** * @private */ - _onURLInputChange: function (ev) { - var $input = $(ev.currentTarget); - - var inputValue = $input.val(); + _onURLInputChange: function () { + var inputValue = this.$urlInput.val(); var emptyValue = (inputValue === ''); var isURL = /^.+\..+$/.test(inputValue); // TODO improve @@ -631,9 +609,33 @@ var FileWidget = SearchableMediaWidget.extend({ }, /** * @private + * @returns {Promise} */ _onUploadURLButtonClick: function () { - this._uploadFile(); + return this._mutex.exec(this._addUrl.bind(this)); + }, + /** + * @private + * @returns {Promise} + */ + _addUrl: function () { + var self = this; + return this._rpc({ + route: '/web_editor/attachment/add_url', + params: { + 'url': this.$urlInput.val(), + 'res_id': this.options.res_id, + 'res_model': this.options.res_model, + 'filters': this.options.firstFilters.join('_'), + }, + }).then(function (attachment) { + self.$urlInput.val(''); + self._onURLInputChange(); + self._handleNewAttachment(attachment); + if (!self.options.multiImages) { + self.trigger_up('save_request'); + } + }); }, /** * @private diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index 9f3101cb1c6..a9e42a88231 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -95,15 +95,7 @@ - -
- - - - +
@@ -113,13 +105,12 @@ All images have been loaded
-