diff options
| author | Samuel Degueldre <sad@odoo.com> | 2020-05-19 09:21:49 +0000 |
|---|---|---|
| committer | qsm-odoo <qsm@odoo.com> | 2020-06-05 18:11:53 +0000 |
| commit | aa8d2a8be2c8f713b4b39b2aa1ea2aaf1839396c (patch) | |
| tree | f1531840d7b262d1e282d14db23a3d0ce6d41571 /addons/web_editor/static/src/js/editor/snippets.options.js | |
| parent | c2de755bc690987f59cda27bd83a7429278955fc (diff) | |
[IMP] web_editor, *: move image optimization feature to left-panel
*: website, mass_mailing
In continuity with the nline crop, in an effort to make the image
workflow as seamless as possible in the website editor, the image
optimization feature has been moved out of the modal which could be
opened through the media-dialog and into the left panel.
This commit adds a quality slider and a width selector, as well as a
preview of the image's weight to the left panel, and removes the
image_optimize dialog.
This commit also introduces the possibility to add a color filter on
images.
Part of https://github.com/odoo/odoo/pull/51517
task-2192755
closes odoo/odoo#51517
Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
Diffstat (limited to 'addons/web_editor/static/src/js/editor/snippets.options.js')
| -rw-r--r-- | addons/web_editor/static/src/js/editor/snippets.options.js | 507 |
1 files changed, 455 insertions, 52 deletions
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 1c603eff10c..ae40fd76a63 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 |
