summaryrefslogtreecommitdiff
path: root/addons/web_editor/static
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web_editor/static')
-rw-r--r--addons/web_editor/static/src/js/backend/field_html.js2
-rw-r--r--addons/web_editor/static/src/js/editor/editor.js2
-rw-r--r--addons/web_editor/static/src/js/editor/image_processing.js133
-rw-r--r--addons/web_editor/static/src/js/editor/rte.js4
-rw-r--r--addons/web_editor/static/src/js/editor/rte.summernote.js56
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.editor.js3
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.options.js507
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/root.js2
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js120
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/image_optimize_dialog.js330
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media.js113
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg.js4
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg.scss9
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg_snippets.scss5
-rw-r--r--addons/web_editor/static/src/xml/wysiwyg.xml1
15 files changed, 661 insertions, 630 deletions
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 82169c53fba..215f7a698ea 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 5cd8113e7f9..1d6f34aa4cf 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 00000000000..e775dfde421
--- /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 bf5c0db613d..71217563ca7 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 92d8cefe0bc..229acc0616c 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 06263b23182..20c73e39e99 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 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
diff --git a/addons/web_editor/static/src/js/wysiwyg/root.js b/addons/web_editor/static/src/js/wysiwyg/root.js
index 8b644b6faae..dc87ff9e0be 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 91f76bba5d4..27444e061af 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 1c655026f32..00000000000
--- 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 645b49c7ead..09fddb2869b 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
*/
@@ -261,20 +251,6 @@ var FileWidget = SearchableMediaWidget.extend({
.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
* field, it is used to search attachments, and the attachments from the current document are
@@ -356,52 +332,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.
*
* @param {Object[]} attachments
@@ -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,31 +421,15 @@ 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
* and to close the media dialog
@@ -577,18 +486,6 @@ var FileWidget = SearchableMediaWidget.extend({
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
* all new files have been processed.
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
index fe7399fdebe..0ba27dcf172 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 b3fa17b9116..9b368607a28 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 1ba4425100f..c31fd32d194 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 a5f4ddb0f74..2e63efb2aac 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 -->