diff --git a/addons/mass_mailing/static/src/js/mass_mailing_widget.js b/addons/mass_mailing/static/src/js/mass_mailing_widget.js index 034da3f20dd4fbdd6a42291f6ead786126394eed..a5cba75e32f003f8db2eb2181f092ecd47156727 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_widget.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_widget.js @@ -58,7 +58,7 @@ var MassMailingFieldHtml = FieldHtml.extend({ var $editable = this.wysiwyg.getEditable(); - return this.wysiwyg.saveCroppedImages(this.$content).then(function () { + return this.wysiwyg.saveModifiedImages(this.$content).then(function () { return self.wysiwyg.save().then(function (result) { self._isDirty = result.isDirty; diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index c037e783b6115b27d1c9827710c81546ed488ef9..2b50098453401351517c068ef54f4fa0c40ca7eb 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -168,31 +168,6 @@ class Web_Editor(http.Controller): attachment = self._attachment_create(url=url, res_id=res_id, res_model=res_model) return attachment._get_media_info() - @http.route('/web_editor/attachment/<model("ir.attachment"):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: - 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 - try: - data['datas'] = tools.image_process(attachment.datas, size=(width, height), quality=quality) - except UserError: - 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) @@ -226,12 +201,10 @@ class Web_Editor(http.Controller): @http.route('/web_editor/get_image_info', type='json', auth='user', website=True) def get_image_info(self, src=''): - """This route is used from image crop widget to get image info. - It is used to display the original image when we crop a previously - cropped image. + """This route is used to determine the original of an attachment so that + it can be used as a base to modify it again (crop/optimization/filters). """ id_match = re.search('^/web/image/([^/?]+)', src) - attachment = [] if id_match: url_segment = id_match.group(1) number_match = re.match('^(\d+)', url_segment) @@ -250,10 +223,10 @@ class Web_Editor(http.Controller): } return { 'attachment': attachment.read(['id'])[0], - 'original': (attachment.original_id or attachment).read(['id', 'image_src', 'mimetype', 'name'])[0], + 'original': (attachment.original_id or attachment).read(['id', 'image_src', 'mimetype'])[0], } - def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view', mimetype=None, original_id=None): + def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view'): """Create and return a new attachment.""" if not name and url: name = url.split("/").pop() @@ -268,8 +241,6 @@ class Web_Editor(http.Controller): 'public': res_model == 'ir.ui.view', 'res_id': res_id, 'res_model': res_model, - 'mimetype': mimetype, - 'original_id': original_id, } if data: @@ -485,12 +456,37 @@ class Web_Editor(http.Controller): View = View.sudo() return View._render_template(xmlid, {k: values[k] for k in values if k in trusted_value_keys}) - @http.route("/web_editor/crop_attachment", type="json", auth="user", website=True) - def crop_attachment(self, res_model=None, res_id=None, name=None, data=None, mimetype=None, original_id=None): + @http.route('/web_editor/modify_image/<model("ir.attachment"):attachment>', type="json", auth="user", website=True) + def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=None, original_id=None): """ - Creates a cropped attachment and returns its image_src to be inserted into the DOM + Creates a modified copy of an attachment and returns its image_src to be + inserted into the DOM. """ - attachment = self._attachment_create(res_model=res_model, res_id=res_id, name=name, data=data, mimetype=mimetype, original_id=original_id) + fields = { + 'original_id': attachment.id, + 'datas': data, + 'type': 'binary', + 'res_model': res_model or 'ir.ui.view', + } + if fields['res_model'] == 'ir.ui.view': + fields['res_id'] = 0 + elif res_id: + fields['res_id'] = res_id + if name: + fields['name'] = name + attachment = attachment.copy(fields) + if attachment.url: + # Don't keep url if modifying static attachment because static images + # are only served from disk and don't fallback to attachments. + if re.match(r'^/\w+/static/', attachment.url): + attachment.url = None + # Uniquify url by adding a path segment with the id before the name. + # This allows us to keep the unsplash url format so it still reacts + # to the unsplash beacon. + else: + url_fragments = attachment.url.split('/') + url_fragments.insert(-1, str(attachment.id)) + attachment.url = '/'.join(url_fragments) if attachment.public: return attachment.image_src attachment.generate_access_token() diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js index 82169c53fbac0212ef4ec36bc4cb8c30330da13b..215f7a698eaebff8bf0aeff63ba9eb2b80271608 100644 --- a/addons/web_editor/static/src/js/backend/field_html.js +++ b/addons/web_editor/static/src/js/backend/field_html.js @@ -107,7 +107,7 @@ var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, { return this._super(); } var _super = this._super.bind(this); - return this.wysiwyg.saveCroppedImages(this.$content).then(function () { + return this.wysiwyg.saveModifiedImages(this.$content).then(function () { return self.wysiwyg.save().then(function (result) { self._isDirty = result.isDirty; _super(); diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js index 5cd8113e7f97660242ba6ec7bbaae3e5bff117ef..1d6f34aa4cf46ea934e6ad04effadac30e954c14 100644 --- a/addons/web_editor/static/src/js/editor/editor.js +++ b/addons/web_editor/static/src/js/editor/editor.js @@ -171,7 +171,7 @@ var EditorMenuBar = Widget.extend({ if (this.snippetsMenu) { await this.snippetsMenu.cleanForSave(); } - await this.getParent().saveCroppedImages(this.rte.editable()); + await this.getParent().saveModifiedImages(this.rte.editable()); await this.rte.save(); if (reload !== false) { diff --git a/addons/web_editor/static/src/js/editor/image_processing.js b/addons/web_editor/static/src/js/editor/image_processing.js new file mode 100644 index 0000000000000000000000000000000000000000..e775dfde42102a51342bef3c089d0db6bd27b773 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/image_processing.js @@ -0,0 +1,133 @@ +odoo.define('web_editor.image_processing', function (require) { +'use strict'; + +// Fields returned by cropperjs 'getData' method, also need to be passed when +// initializing the cropper to reuse the previous crop. +const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY']; +const modifierFields = [ + 'filter', + 'quality', + 'mimetype', + 'originalId', + 'originalSrc', + 'resizeWidth', + 'aspectRatio', +]; +/** + * Applies data-attributes modifications to an img tag and returns a dataURL + * containing the result. This function does not modify the original image. + * + * @param {HTMLImageElement} img the image to which modifications are applied + * @returns {string} dataURL of the image with the applied modifications + */ +async function applyModifications(img) { + const data = Object.assign({ + filter: '#0000', + quality: '95', + }, img.dataset); + let {width, height, resizeWidth, quality, filter, mimetype, originalSrc} = data; + [width, height, resizeWidth] = [width, height, resizeWidth].map(s => parseFloat(s)); + quality = parseInt(quality); + + // Crop + const container = document.createElement('div'); + const original = await loadImage(originalSrc); + container.appendChild(original); + await activateCropper(original, 0, data); + const croppedImg = $(original).cropper('getCroppedCanvas', {width, height}); + $(original).cropper('destroy'); + + // Width + const result = document.createElement('canvas'); + result.width = resizeWidth || croppedImg.width; + result.height = croppedImg.height * result.width / croppedImg.width; + const ctx = result.getContext('2d'); + ctx.drawImage(croppedImg, 0, 0, croppedImg.width, croppedImg.height, 0, 0, result.width, result.height); + + // Color filter + ctx.fillStyle = filter || '#0000'; + ctx.fillRect(0, 0, result.width, result.height); + + // Quality + return result.toDataURL(mimetype, quality / 100); +} + +/** + * Loads an src into an HTMLImageElement. + * + * @param {String} src URL of the image to load + * @param {HTMLImageElement} [img] img element in which to load the image + * @returns {Promise<HTMLImageElement>} Promise that resolves to the loaded img + */ +function loadImage(src, img = new Image()) { + return new Promise((resolve, reject) => { + img.addEventListener('load', () => resolve(img), {once: true}); + img.addEventListener('error', reject, {once: true}); + img.src = src; + }); +} + +// Because cropperjs acquires images through XHRs on the image src and we don't +// want to load big images over the network many times when adjusting quality +// and filter, we create a local cache of the images using object URLs. +const imageCache = new Map(); +/** + * Activates the cropper on a given image. + * + * @param {jQuery} $image the image on which to activate the cropper + * @param {Number} aspectRatio the aspectRatio of the crop box + * @param {DOMStringMap} dataset dataset containing the cropperDataFields + */ +async function activateCropper(image, aspectRatio, dataset) { + const src = image.getAttribute('src'); + if (!imageCache.has(src)) { + const res = await fetch(src); + imageCache.set(src, URL.createObjectURL(await res.blob())); + } + image.src = imageCache.get(src); + $(image).cropper({ + viewMode: 2, + dragMode: 'move', + autoCropArea: 1.0, + aspectRatio: aspectRatio, + data: _.mapObject(_.pick(dataset, ...cropperDataFields), value => parseFloat(value)), + // Can't use 0 because it's falsy and cropperjs will then use its defaults (200x100) + minContainerWidth: 1, + minContainerHeight: 1, + }); + return new Promise(resolve => image.addEventListener('ready', resolve, {once: true})); +} +/** + * Marks an <img> with its attachment data (originalId, originalSrc, mimetype) + * + * @param {HTMLImageElement} img the image whose attachment data should be found + * @param {Function} rpc a function that can be used to make the RPC. Typically + * this would be passed as 'this._rpc.bind(this)' from widgets. + */ +async function loadImageInfo(img, rpc) { + // If there is a marked originalSrc, the data is already loaded. + if (img.dataset.originalSrc) { + return; + } + + const {original} = await rpc({ + route: '/web_editor/get_image_info', + params: {src: img.getAttribute('src').split(/[?#]/)[0]}, + }); + // Check that url is local. + if (original && new URL(original.image_src, window.location.origin).origin === window.location.origin) { + img.dataset.originalId = original.id; + img.dataset.originalSrc = original.image_src; + img.dataset.mimetype = original.mimetype; + } +} + +return { + applyModifications, + cropperDataFields, + activateCropper, + loadImageInfo, + loadImage, + removeOnImageChangeAttrs: [...cropperDataFields, ...modifierFields, 'aspectRatio'], +}; +}); diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js index bf5c0db613d655b0878a3c619dae1150e1b9c48a..71217563ca751cd95199f0459b67749b22930840 100644 --- a/addons/web_editor/static/src/js/editor/rte.js +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -458,6 +458,10 @@ var RTEWidget = Widget.extend({ if (initialActiveElement && initialActiveElement !== document.activeElement) { initialActiveElement.focus(); + // Range inputs don't support selection + if (initialActiveElement.matches('input[type=range]')) { + return; + } try { initialActiveElement.selectionStart = initialSelectionStart; initialActiveElement.selectionEnd = initialSelectionEnd; 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 92d8cefe0bc2862bb3518d0aa72f412fcd263139..229acc0616cacc1c8fd2902c67d084767114a367 100644 --- a/addons/web_editor/static/src/js/editor/rte.summernote.js +++ b/addons/web_editor/static/src/js/editor/rte.summernote.js @@ -12,7 +12,6 @@ const topBus = window.top.odoo.__DEBUG__.services['web.core'].bus; const {ColorpickerWidget} = require('web.Colorpicker'); var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget; var mixins = require('web.mixins'); -const session = require('web.session'); var fonts = require('wysiwyg.fonts'); var rte = require('web_editor.rte'); var ServicesMixin = require('web.ServicesMixin'); @@ -1092,32 +1091,36 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, }, /** - * Create/Update cropped attachments. + * Create modified image attachments. * * @param {jQuery} $editable * @returns {Promise} */ - saveCroppedImages: function ($editable) { - var defs = _.map($editable.find('.o_cropped_img_to_save'), async croppedImg => { - 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]; + saveModifiedImages: function ($editable) { + const defs = _.map($editable, async editableEl => { + const {oeModel: resModel, oeId: resId} = editableEl.dataset; + const proms = [...editableEl.querySelectorAll('.o_modified_image_to_save')].map(async el => { + const isBackground = !el.matches('img'); + el.classList.remove('o_modified_image_to_save'); + // Modifying an image always creates a copy of the original, even if + // it was modified previously, as the other modified image may be used + // elsewhere if the snippet was duplicated or was saved as a custom one. + const newAttachmentSrc = await this._rpc({ + route: `/web_editor/modify_image/${el.dataset.originalId}`, + params: { + res_model: resModel, + res_id: parseInt(resId), + data: (isBackground ? el.dataset.bgSrc : el.getAttribute('src')).split(',')[1], + }, + }); + if (isBackground) { + $(el).css('background-image', `url('${newAttachmentSrc}')`); + delete el.dataset.bgSrc; + } else { + el.setAttribute('src', newAttachmentSrc); + } }); + return Promise.all(proms); }); return Promise.all(defs); }, @@ -1160,13 +1163,8 @@ var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, return; } data.__alreadyDone = true; - new weWidgets.ImageCropWidget(this, - _.extend({ - res_model: data.$editable.data('oe-model'), - res_id: data.$editable.data('oe-id'), - }, data.options || {}), - data.media - ).appendTo(data.$editable); + new weWidgets.ImageCropWidget(this, data.media) + .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 06263b231824e64a4dfa00c8acfb2d227dcbb202..20c73e39e99f37bec5981fc711a64b42ea039cf1 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -219,6 +219,9 @@ var SnippetEditor = Widget.extend({ if (this.$target.data('name') !== undefined) { return this.$target.data('name'); } + if (this.$target.is('img')) { + return _t("Image"); + } if (this.$target.parent('.row').length) { return _t("Column"); } diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 1c603eff10cbfd5c43d1002d975c22166f2499a1..ae40fd76a635b59657ee4166f95161ab107b2898 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -9,6 +9,12 @@ var Widget = require('web.Widget'); var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget; const weUtils = require('web_editor.utils'); var weWidgets = require('wysiwyg.widgets'); +const { + loadImage, + loadImageInfo, + applyModifications, + removeOnImageChangeAttrs, +} = require('web_editor.image_processing'); var qweb = core.qweb; var _t = core._t; @@ -1424,6 +1430,49 @@ const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ }, }); +const RangeUserValueWidget = UserValueWidget.extend({ + tagName: 'we-range', + events: { + 'change input': '_onInputChange', + }, + + /** + * @override + */ + async start() { + await this._super(...arguments); + this.input = document.createElement('input'); + this.input.type = "range"; + this.input.className = "custom-range"; + this.containerEl.appendChild(this.input); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + setValue(value, methodName) { + this.input.value = value; + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onInputChange(ev) { + this._value = ev.target.value; + this._onUserValueChange(ev); + }, +}); + + const userValueWidgetsRegistry = { 'we-button': ButtonUserValueWidget, 'we-checkbox': CheckboxUserValueWidget, @@ -1433,6 +1482,7 @@ const userValueWidgetsRegistry = { 'we-colorpicker': ColorpickerUserValueWidget, 'we-datetimepicker': DatetimePickerUserValueWidget, 'we-imagepicker': ImagepickerUserValueWidget, + 'we-range': RangeUserValueWidget, }; /** @@ -2662,6 +2712,385 @@ registry['sizing_y'] = registry.sizing.extend({ }, }); +/* + * Abstract option to be extended by the ImageOptimize and BackgroundOptimize + * options that handles all the common parts. + */ +const ImageHandlerOption = SnippetOptionWidget.extend({ + + /** + * @override + */ + async willStart() { + const _super = this._super.bind(this); + await this._loadImageInfo(); + // Make sure image is loaded because we need its naturalWidth to render XML + const img = this._getImg(); + await new Promise((resolve, reject) => { + if (img.complete) { + return resolve(); + } + img.addEventListener('load', resolve, {once: true}); + img.addEventListener('error', resolve, {once: true}); + }); + return _super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + selectWidth(previewMode, widgetValue, params) { + this._getImg().dataset.resizeWidth = widgetValue; + return this._applyOptions(); + }, + /** + * @see this.selectClass for parameters + */ + setFilter(previewMode, widgetValue, params) { + this._getImg().dataset.filter = this._normalizeColor(widgetValue); + return this._applyOptions(); + }, + /** + * @see this.selectClass for parameters + */ + setQuality(previewMode, widgetValue, params) { + this._getImg().dataset.quality = widgetValue; + return this._applyOptions(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeVisibility() { + const src = this._getImg().getAttribute('src'); + return src && src !== '/'; + }, + /** + * @override + */ + _computeWidgetState(methodName, params) { + const img = this._getImg(); + switch (methodName) { + case 'selectWidth': + return img.naturalWidth; + case 'setFilter': + return img.dataset.filter; + case 'setQuality': + return img.dataset.quality || 95; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + if (!this.originalSrc) { + return [...uiFragment.childNodes].forEach(node => { + if (node.matches('.o_we_external_warning')) { + node.classList.remove('d-none'); + } else { + node.remove(); + } + }); + } + const img = this._getImg(); + const $select = $(uiFragment).find('we-select[data-name=width_select_opt]'); + (await this._computeAvailableWidths()).forEach(([value, label]) => { + $select.append(`<we-button data-select-width="${value}">${label}</we-button>`); + }); + const qualityRange = uiFragment.querySelector('we-range'); + if (img.dataset.mimetype !== 'image/jpeg') { + qualityRange.remove(); + } + }, + /** + * Returns a list of valid widths for a given image. + * + * @private + */ + async _computeAvailableWidths() { + const img = this._getImg(); + const original = await loadImage(this.originalSrc); + const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth; + const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth()); + this.optimizedWidth = optimizedWidth; + const widths = { + 128: '128px', + 256: '256px', + 512: '512px', + 1024: '1024px', + 1920: '1920px', + }; + widths[img.naturalWidth] = _.str.sprintf(_t("%spx"), img.naturalWidth); + widths[optimizedWidth] = _.str.sprintf(_t("%dpx (Suggested)"), optimizedWidth); + widths[maxWidth] = _.str.sprintf(_t("%dpx (Original)"), maxWidth); + return Object.entries(widths) + .filter(([width]) => width <= maxWidth) + .sort(([v1], [v2]) => v1 - v2); + }, + /** + * Applies all selected options on the original image. + * + * @private + */ + async _applyOptions() { + const img = this._getImg(); + if (!['image/jpeg', 'image/png'].includes(img.dataset.mimetype)) { + this.originalId = null; + return; + } + const dataURL = await applyModifications(img); + const weight = dataURL.split(',')[1].length / 4 * 3; + this.$el.find('.o_we_image_weight').text(`${(weight / 1024).toFixed(1)}kb`); + img.classList.add('o_modified_image_to_save'); + return loadImage(dataURL, img); + }, + /** + * Loads the image's attachment info. + * + * @private + */ + async _loadImageInfo() { + const img = this._getImg(); + await loadImageInfo(img, this._rpc.bind(this)); + if (!img.dataset.originalId) { + this.originalId = null; + this.originalSrc = null; + return; + } + this.originalId = img.dataset.originalId; + this.originalSrc = img.dataset.originalSrc; + }, + /** + * Returns the image that is currently being modified. + * + * @private + * @abstract + * @returns {HTMLImageElement} the image to use for modifications + */ + _getImg() {}, + /** + * Computes the image's maximum display width. + * + * @private + * @abstract + * @returns {Int} the maximum width at which the image can be displayed + */ + _computeMaxDisplayWidth() {}, + + //-------------------------------------------------------------------------- + // Util + //-------------------------------------------------------------------------- + + /** + * Normalize a color into a css value usable by the canvas rendering context. + * + * @private + * @param {string} color the color to normalize into a css value + */ + _normalizeColor(color) { + if (!ColorpickerWidget.isCSSColor(color)) { + const style = window.getComputedStyle(document.documentElement); + color = style.getPropertyValue('--' + color).trim(); + color = ColorpickerWidget.normalizeCSSColor(color); + } + return color; + }, +}); + +/** + * Controls image width and quality. + */ +registry.ImageOptimize = ImageHandlerOption.extend({ + /** + * @override + */ + start() { + this.$target.on('image_changed.ImageOptimization', this._onImageChanged.bind(this)); + this.$target.on('image_cropped.ImageOptimization', this._onImageCropped.bind(this)); + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this.$target.off('.ImageOptimization'); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeMaxDisplayWidth() { + // TODO: read widths from computed style in case container widths are not default + const displayWidth = this._getImg().clientWidth; + // If the image is in a column, it might get bigger on smaller screens. + // We use col-lg for this in snippets, so they get bigger on the md breakpoint + if (this.$target.closest('[class*="col-lg"]').length) { + // container and o_container_small have maximum inner width of 690px on the md breakpoint + if (this.$target.closest('.container, .o_container_small').length) { + return Math.min(1920, Math.max(displayWidth, 690)); + } + // A container-fluid's max inner width is 962px on the md breakpoint + return Math.min(1920, Math.max(displayWidth, 962)); + } + // If it's not in a col-lg, it's probably not going to change size depending on breakpoints + return displayWidth; + }, + /** + * @override + */ + _getImg() { + return this.$target[0]; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Reloads image data and auto-optimizes the new image. + * + * @private + * @param {Event} ev + */ + async _onImageChanged(ev) { + this.trigger_up('snippet_edition_request', {exec: async () => { + await this._loadImageInfo(); + await this._rerenderXML(); + this._getImg().dataset.resizeWidth = this.optimizedWidth; + await this._applyOptions(); + await this.updateUI(); + }}); + }, + /** + * Available widths will change, need to rerender the width select. + * + * @private + * @param {Event} ev + */ + async _onImageCropped(ev) { + await this._rerenderXML(); + }, +}); + +/** + * Returns the src value from a css value related to a background image + * (e.g. "url('blabla')" => "blabla" / "none" => ""). + * + * @param {string} value + * @returns {string} + */ +const getSrcFromCssValue = value => { + var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g; + return value && value.replace(srcValueWrapper, '') || ''; +}; + +/** + * Controls background image width and quality. + */ +registry.BackgroundOptimize = ImageHandlerOption.extend({ + /** + * @override + */ + start() { + this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this)); + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this.$target.off('.BackgroundOptimize'); + return this._super(...arguments); + }, + /** + * Marks the target for creation of an attachment and copies data attributes + * to the target so that they can be restored on this.img in later editions. + * + * @override + */ + async cleanForSave() { + const img = this._getImg(); + if (img.matches('.o_modified_image_to_save')) { + this.$target.addClass('o_modified_image_to_save'); + Object.entries(img.dataset).forEach(([key, value]) => { + this.$target[0].dataset[key] = value; + }); + this.$target[0].dataset.bgSrc = img.getAttribute('src'); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getImg() { + return this.img; + }, + /** + * @override + */ + _computeMaxDisplayWidth() { + return 1920; + }, + /** + * @override + */ + async _applyOptions() { + await this._super(...arguments); + this.$target.css('background-image', `url('${this._getImg().getAttribute('src')}')`); + }, + /** + * Initializes this.img to an image with the background image url as src. + * + * @override + */ + async _loadImageInfo() { + this.img = new Image(); + Object.entries(this.$target[0].dataset).forEach(([key, value]) => { + this.img.dataset[key] = value; + }); + const src = new URL(getSrcFromCssValue(this.$target.css('background-image')), window.location.origin); + // Make URL relative because that is how image urls are stored in the database. + this.img.src = src.origin === window.location.origin ? src.pathname : src; + return await this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Reloads image data when the background is changed. + * + * @private + */ + async _onBackgroundChanged(ev, previewMode) { + if (!previewMode) { + this.trigger_up('snippet_edition_request', {exec: async () => { + await this._loadImageInfo(); + await this._rerenderXML(); + }}); + } + }, +}); + /** * Handles the edition of snippet's background image. */ @@ -2687,12 +3116,14 @@ registry.background = SnippetOptionWidget.extend({ * @see this.selectClass for parameters */ background: async function (previewMode, widgetValue, params) { - if (previewMode === 'reset') { - return this._setCustomBackground(this.__customImageSrc, previewMode); - } - if (!previewMode) { + if (previewMode === true) { + this.__customImageSrc = this._getSrcFromCssValue(); + } else if (previewMode === 'reset') { + widgetValue = this.__customImageSrc; + } else { this.__customImageSrc = widgetValue; } + if (widgetValue) { this.$target.css('background-image', `url('${widgetValue}')`); this.$target.addClass('oe_img_bg'); @@ -2700,6 +3131,20 @@ registry.background = SnippetOptionWidget.extend({ this.$target.css('background-image', ''); this.$target.removeClass('oe_img_bg'); } + + if (previewMode === 'reset') { + return new Promise(resolve => { + // Will update the UI of the correct widgets for all options + // related to the same $target/editor + this.trigger_up('snippet_option_update', { + previewMode: 'reset', + onSuccess: () => resolve(), + }); + }); + } else { + removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]); + this.$target.trigger('background_changed', [previewMode]); + } }, //-------------------------------------------------------------------------- @@ -2732,38 +3177,13 @@ registry.background = SnippetOptionWidget.extend({ //-------------------------------------------------------------------------- /** - * Returns the src value from a css value related to a background image - * (e.g. "url('blabla')" => "blabla" / "none" => ""). + * Returns the background image's src. * * @private - * @param {string} value * @returns {string} */ - _getSrcFromCssValue: function (value) { - if (value === undefined) { - value = this.$target.css('background-image'); - } - var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g; - return value && value.replace(srcValueWrapper, '') || ''; - }, - /** - * Sets the given value as custom background image. - * - * @private - * @param {string} value - * @returns {Promise} - */ - _setCustomBackground: async function (value, previewMode) { - this.__customImageSrc = value; - this.background(false, this.__customImageSrc, {}); - await new Promise(resolve => { - // Will update the UI of the correct widgets for all options - // related to the same $target/editor - this.trigger_up('snippet_option_update', { - previewMode: previewMode, - onSuccess: () => resolve(), - }); - }); + _getSrcFromCssValue: function () { + return getSrcFromCssValue(this.$target.css('background-image')); }, /** * @override @@ -2801,17 +3221,6 @@ registry.background = SnippetOptionWidget.extend({ await this.background(previewMode, '', {}); return true; }, - /** - * Called on media dialog save (when choosing a snippet's background) -> - * sets the resulting media as the snippet's background somehow. - * - * @private - * @param {Object} data - * @returns {Promise} - */ - _onSaveMediaDialog: async function (data) { - await this._setCustomBackground($(data).attr('src')); - }, }); /** @@ -3009,19 +3418,13 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ window.setTimeout(() => $(document).on('click.bgposition', this._onDocumentClicked.bind(this)), 0); }, /** - * Returns the src value from a css value related to a background image - * (e.g. "url('blabla')" => "blabla" / "none" => ""). + * Returns the background image's src. * * @private - * @param {string} value * @returns {string} */ - _getSrcFromCssValue: function (value) { - if (value === undefined) { - value = this.$target.css('background-image'); - } - var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g; - return value && value.replace(srcValueWrapper, '') || ''; + _getSrcFromCssValue: function () { + return getSrcFromCssValue(this.$target.css('background-image')); }, /** * Returns the difference between the target's size and the background's diff --git a/addons/web_editor/static/src/js/wysiwyg/root.js b/addons/web_editor/static/src/js/wysiwyg/root.js index 8b644b6faaef0dc10837360386b6647acd04dd69..dc87ff9e0be46d07b31586a66923101f2435e8c6 100644 --- a/addons/web_editor/static/src/js/wysiwyg/root.js +++ b/addons/web_editor/static/src/js/wysiwyg/root.js @@ -9,7 +9,7 @@ var WysiwygRoot = Widget.extend({ assetLibs: ['web_editor.compiled_assets_wysiwyg'], _loadLibsTplRoute: '/web_editor/public_render_template', - publicMethods: ['isDirty', 'save', 'getValue', 'setValue', 'getEditable', 'on', 'trigger', 'focus', 'saveCroppedImages'], + publicMethods: ['isDirty', 'save', 'getValue', 'setValue', 'getEditable', 'on', 'trigger', 'focus', 'saveModifiedImages'], /** * @see 'web_editor.wysiwyg' module 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 91f76bba5d4c2ff125965f4adaf59e08e5b38d58..27444e061af29cdf6de8defdefa26190d34476c4 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 @@ -2,13 +2,11 @@ odoo.define('wysiwyg.widgets.ImageCropWidget', function (require) { 'use strict'; const core = require('web.core'); -const qweb = core.qweb; const Widget = require('web.Widget'); +const {applyModifications, cropperDataFields, activateCropper, loadImage, loadImageInfo} = require('web_editor.image_processing'); const _t = core._t; -// 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'], @@ -17,41 +15,16 @@ const ImageCropWidget = Widget.extend({ // 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(parent, options, media) { + init(parent, media) { this._super(...arguments); this.media = media; 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}, @@ -65,48 +38,26 @@ const ImageCropWidget = Widget.extend({ 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 */ async willStart() { await this._super.apply(this, arguments); - if (!this.isCroppable) { - return; - } - // 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. + await loadImageInfo(this.media, this._rpc.bind(this)); if (this.media.dataset.originalSrc) { - this.sessionAttributes.forEach(attr => { - this[attr] = this.media.dataset[attr]; - }); - return; - } - - // 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; + this.originalSrc = this.media.dataset.originalSrc; + this.originalId = this.media.dataset.originalId; return; } - this.originalId = original.id; - this.originalSrc = original.image_src; - this.originalName = original.name; - this.mimetype = original.mimetype; - this.attachmentId = attachment.id; + // Couldn't find an attachment: not croppable. + this.uncroppable = true; }, /** * @override */ async start() { - if (!this.isCroppable) { + if (this.uncroppable) { this.displayNotification({ type: 'warning', title: _t("This image is an external image"), @@ -118,8 +69,7 @@ const ImageCropWidget = Widget.extend({ 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})); + await loadImage(this.originalSrc, this.media); 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']; @@ -130,18 +80,8 @@ const ImageCropWidget = Widget.extend({ 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, - }); + await loadImage(this.originalSrc, cropperImage); + await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset); core.bus.trigger('deactivate_snippet'); this._onDocumentMousedown = this._onDocumentMousedown.bind(this); @@ -173,27 +113,20 @@ const ImageCropWidget = Widget.extend({ * * @private */ - _save() { - // Mark the media for later creation/update of cropped attachment - this.media.classList.add('o_cropped_img_to_save'); + async _save() { + // Mark the media for later creation of cropped attachment + this.media.classList.add('o_modified_image_to_save'); - this.allAttributes.forEach(attr => { + [...cropperDataFields, 'aspectRatio'].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 - const cropperData = this.$cropperImage.cropper('getData'); - const canvas = this.$cropperImage.cropper('getCroppedCanvas', { - width: cropperData.width, - height: cropperData.height, - }); - // 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 + delete this.media.dataset.resizeWidth; + this.initialSrc = await applyModifications(this.media); + this.$media.trigger('image_cropped'); this.destroy(); }, /** @@ -202,12 +135,6 @@ const ImageCropWidget = Widget.extend({ * @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]; } @@ -282,16 +209,5 @@ const ImageCropWidget = Widget.extend({ }, }); -const proto = ImageCropWidget.prototype; -proto.allAttributes = [...new Set([ - ...proto.persistentAttributes, - ...proto.sessionAttributes, - ...proto.saveAttributes, -])]; -proto.removeOnSaveAttributes = [...new Set([ - ...proto.sessionAttributes, - ...proto.saveAttributes, -])]; - return ImageCropWidget; }); 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 deleted file mode 100644 index 1c655026f32c9dfc6b348b9b01a0ec80d0176d0b..0000000000000000000000000000000000000000 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js +++ /dev/null @@ -1,330 +0,0 @@ -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.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(optimizedWidth || this.image_width, this.image_width); - this.defaultQuality = this.isExisting ? 100 : 80; - 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'); - 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.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 - */ - 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.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.original_id || [this.attachment.id])[0], 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.image_width) { - isValid = false; - } - if (height < 0 || height > this.image_height) { - isValid = false; - } - return isValid; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onHeightInput: function () { - var height = parseInt(this.$heightInput.val()) || 0; - this.$widthInput - .val(parseInt(height / this.image_height * this.image_width)); - this._updatePreview(); - this._validateForm(); - }, - /** - * @private - */ - _onKeepOriginalClick: function () { - if (!this.isExisting) { - 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 && !this.attachment.original_id) { - 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.image_width * this.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 645b49c7ead774e0bda21d3478539f0b994492b2..09fddb2869b6a73c7f82a1781d584601c0a70eba 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -6,11 +6,10 @@ 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 session = require('web.session'); -const ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget'); +const {removeOnImageChangeAttrs} = require('web_editor.image_processing'); var QWeb = core.qweb; var _t = core._t; @@ -123,7 +122,6 @@ var FileWidget = SearchableMediaWidget.extend({ 'input .o_we_url_input': '_onURLInputChange', 'click .o_existing_attachment_cell': '_onAttachmentClick', 'click .o_existing_attachment_remove': '_onRemoveClick', - 'click .o_existing_attachment_optimize': '_onExistingOptimizeClick', 'click .o_load_more': '_onLoadMoreClick', }), existingAttachmentsTemplate: undefined, @@ -131,14 +129,6 @@ 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 */ @@ -260,20 +250,6 @@ 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 @@ -355,52 +331,6 @@ var FileWidget = SearchableMediaWidget.extend({ } }); }, - /** - * 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, $attachmentCell) { - 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'); - if (self.$media[0].getAttribute('src') === attachment.image_src) { - self.$media[0].src = ev.data.image_src; - } - Object.assign(attachment, ev.data); - $attachmentCell.find('img')[0].src = attachment.image_src; - resolve(attachment); - }); - optimizeDialog.on('closed', self, function () { - self.noSave = true; - if (isExisting) { - reject(); - } else { - resolve(attachment); - } - }); - }); - var always = () => { - 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,11 +376,6 @@ var FileWidget = SearchableMediaWidget.extend({ 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); - } - if (!img.public && !img.access_token) { await this._rpc({ model: 'ir.attachment', @@ -496,30 +421,14 @@ var FileWidget = SearchableMediaWidget.extend({ this.$media.css(style); } - // Remove crop related attributes - ImageCropWidget.prototype.allAttributes.forEach(attr => { + // Remove image modification attributes + removeOnImageChangeAttrs.forEach(attr => { delete this.media.dataset[attr]; }); - this.media.classList.remove('o_cropped_img_to_save'); + this.media.classList.remove('o_modified_image_to_save'); + this.$media.trigger('image_changed'); 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(), - }, - }); - }, /** * @param {object} attachment * @param {boolean} [save=true] to save the given attachment in the DOM and @@ -576,18 +485,6 @@ var FileWidget = SearchableMediaWidget.extend({ var attachment = _.find(this.attachments, {id: $attachment.data('id')}); this._selectAttachement(attachment, !this.options.multiImages); }, - /** - * @private - */ - _onExistingOptimizeClick: function (ev) { - 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, $a).then(newAttachment => { - this._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 diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index fe7399fdebeab91dac3b0e9656706025ffc7b0e7..0ba27dcf17258c02bd30a55057a59c9f9aa39cf5 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -158,8 +158,8 @@ var Wysiwyg = Widget.extend({ * @param {jQuery} $editable * @returns {Promise} */ - saveCroppedImages: function ($editable) { - return this._summernoteManager.saveCroppedImages($editable); + saveModifiedImages: function ($editable) { + return this._summernoteManager.saveModifiedImages($editable); }, /** * @param {String} value diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss index b3fa17b91162af054265a88e160eb4f8af560bdf..9b368607a2882d6ddd382f3ac3eab66ded35bc2f 100644 --- a/addons/web_editor/static/src/scss/wysiwyg.scss +++ b/addons/web_editor/static/src/scss/wysiwyg.scss @@ -363,9 +363,7 @@ body .modal { // Highlight selected image/icon %o-we-selected-image { - $o-selected-image-color: rgba(150, 150, 220, 0.3); - background: $o-selected-image-color; - outline: 3px solid $o-selected-image-color; + outline: 3px solid rgba(150, 150, 220, 0.3); } img.o_we_selected_image { @@ -375,6 +373,11 @@ img.o_we_selected_image { .fa.o_we_selected_image::before { @extend %o-we-selected-image; } +// Override default image selection color from portal. It prevents your from +// seeing the images' quality clearly in the wysiwyg. +img::selection { + background: transparent; +} .fa.mx-auto, img.o_we_custom_image.mx-auto { diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index 1ba4425100f1541660f9c85034eba8a31b72a20a..c31fd32d194c806b637ba52c3ad64c80217e7dc6 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -539,6 +539,11 @@ body.editor_enable.editor_has_snippets { } } + we-range input { + flex-grow: 1; + width: 1px; + } + //---------------------------------------------------------------------- // Layout Utils //---------------------------------------------------------------------- diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index a5f4ddb0f74d48e8686fb9b7dfdc466be82c0e43..2e63efb2aac62f0e429d269840f40a44730dd225 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -307,7 +307,6 @@ <t t-name="wysiwyg.widgets.image.existing.attachment"> <t t-set="isOptimized" t-value="!!attachment.original_id"/> <div t-attf-class="o_existing_attachment_cell position-relative bg-light #{isOptimized and 'o_we_attachment_optimized d-none'}" t-att-data-id="attachment.id"> - <i t-if="attachment.type === 'binary'" class="fa fa-cog o_existing_attachment_optimize p-2" title="Optimize" role="img" aria-label="Optimize"/> <t t-call="wysiwyg.widgets.file.existing.remove"/> <div t-attf-class="d-flex align-items-center justify-content-center h-100 w-100"> <!-- 256 is 2 * ImageWidget.MIN_ROW_HEIGHT, hopefully the highest display width of any image --> diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml index 873e45d64c3a0063a34efc02f987dcf5592e6118..2c340c56c6110550c10027fcc505dcd00d2b6fff 100644 --- a/addons/web_editor/views/editor.xml +++ b/addons/web_editor/views/editor.xml @@ -39,10 +39,10 @@ <script type="text/javascript" src="/web_editor/static/src/js/editor/editor.js"/> <script type="text/javascript" src="/web_editor/static/src/js/editor/rte.js"/> <script type="text/javascript" src="/web_editor/static/src/js/editor/rte.summernote.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/editor/image_processing.js"/> <!-- widgets & plugins --> - <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js"/> <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/media.js"/> <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/dialog.js"/> <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js"/> diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml index 91169152a6cf0815a5f6d7f69467dd40ad22957a..cbadd7fb7e7700fb846616d658f3b4f882df607e 100644 --- a/addons/web_editor/views/snippets.xml +++ b/addons/web_editor/views/snippets.xml @@ -37,6 +37,19 @@ </div> </template> +<template id="image_optimization_controls"> + <we-range string="Quality" data-set-quality=""/> + <!-- Select filled in JS --> + <we-select string="Width" data-name="width_select_opt" data-no-preview="true"/> + <span class="o_we_image_weight text-right"/> + <we-colorpicker string="Color filter" data-set-filter="" data-no-preview="true" data-excluded="common, theme"/> + <div class="o_we_external_warning d-none"> + <we-title title="Quality options are unavailable for external images. If you want to change this image's quality, please first download it from the original source and upload it in Odoo."> + <i class="fa fa-warning" /> External Image + </we-title> + </div> +</template> + <!-- options (data-selector, data-drop-in, data-drop-near, data-js to link js object ) --> <template id="snippet_options"> <!-- t-field --> @@ -49,6 +62,11 @@ <div data-js="VersionControl" data-selector="[data-snippet]"/> + + <div data-js="ImageOptimize" + data-selector="img"> + <t t-call="web_editor.image_optimization_controls"/> + </div> </template> <!-- default block --> diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index d31c5c6b4ef984ffb1044258d9578453dfa1103e..18599dfa9559657881553d0585a11a0cd586cf5f 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -518,6 +518,18 @@ options.Class.include({ }, }); +options.registry.BackgroundOptimize.include({ + /** + * @override + */ + _computeVisibility() { + if (this.$target.hasClass('o_background_video')) { + return false; + } + return this._super(...arguments); + }, +}); + options.registry.background.include({ background: async function (previewMode, widgetValue, params) { if (previewMode === 'reset' && this.videoSrc) { diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml index 4ba548d7bd78ce3da85fa16f44bb700fdcddba13..5f66dd3fffdd98d96b9e8d3850b92f8bb9577c8b 100644 --- a/addons/website/views/snippets/snippets.xml +++ b/addons/website/views/snippets/snippets.xml @@ -282,10 +282,17 @@ </div> <!-- Background Image --> + <t t-set="bg_selector" t-value="'section, .parallax, .carousel-item'"/> + <t t-set="bg_exclude" t-value="'.s_hr, .s_image_gallery, .o_gallery .carousel-item'"/> <div data-js="background" string="Background" - data-selector="section, .parallax, .carousel-item" - data-exclude=".s_hr, .s_image_gallery, .o_gallery .carousel-item"> - <we-imagepicker string="Background" data-background="" data-allow-videos=":not(.parallax, .s_parallax_bg)"/> + t-att-data-selector="bg_selector" + t-att-data-exclude="bg_exclude"> + <we-imagepicker string="Background" data-background="" data-allow-videos=":not(.parallax, .s_parallax_bg)" data-name="bg_img_opt"/> + </div> + <div data-js="BackgroundOptimize" string="Background" + t-att-data-selector="bg_selector" + t-att-data-exclude="bg_exclude"> + <t t-call="web_editor.image_optimization_controls"/> </div> <!-- Background Image Size/Position -->