[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>
This commit is contained in:
Samuel Degueldre
2020-06-05 18:11:53 +00:00
committed by qsm-odoo
parent c2de755bc6
commit aa8d2a8be2
21 changed files with 736 additions and 672 deletions
@@ -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;
+33 -37
View File
@@ -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()
@@ -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();
@@ -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) {
@@ -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'],
};
});
@@ -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;
@@ -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.
@@ -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");
}
@@ -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
@@ -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
@@ -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];
});
this.originalSrc = this.media.dataset.originalSrc;
this.originalId = this.media.dataset.originalId;
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;
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;
});
@@ -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,
};
});
@@ -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
@@ -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
@@ -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 {
@@ -539,6 +539,11 @@ body.editor_enable.editor_has_snippets {
}
}
we-range input {
flex-grow: 1;
width: 1px;
}
//----------------------------------------------------------------------
// Layout Utils
//----------------------------------------------------------------------
@@ -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 -->
+1 -1
View File
@@ -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"/>
+18
View File
@@ -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 -->
@@ -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) {
+10 -3
View File
@@ -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 -->