[IMP] web, web_editor, *: add media image optimize dialog
* = website, website_blog The goal is to give the user an opportunity to optimize his images before using them. For this we introduce a preview/configuration dialog after every image upload, where appropriate default values are filled for the quality and resolution, based on where the image is going to be used. Since this is not going to be perfect all the time, we still allow the user to configure them, and we display a preview to ease this process. Indeed it is important for SEO and for usability in general that the images are as light as possible in size. Technically the original image is uploaded and saved first, and then it can be optimized. This way the upload only happens once, and the preview can be computed from the already saved image. task-1930726 PR: #31208
This commit is contained in:
@@ -1065,11 +1065,12 @@ class Binary(http.Controller):
|
||||
# other kwargs are ignored on purpose
|
||||
return self._content_image(xmlid=xmlid, model=model, id=id, field=field,
|
||||
filename_field=filename_field, unique=unique, filename=filename, mimetype=mimetype,
|
||||
download=download, width=width, height=height, crop=crop, access_token=access_token)
|
||||
download=download, width=width, height=height, crop=crop,
|
||||
quality=int(kwargs.get('quality', 0)), access_token=access_token)
|
||||
|
||||
def _content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas',
|
||||
filename_field='datas_fname', unique=None, filename=None, mimetype=None,
|
||||
download=None, width=0, height=0, crop=False, access_token=None,
|
||||
download=None, width=0, height=0, crop=False, quality=0, access_token=None,
|
||||
placeholder='placeholder.png', **kwargs):
|
||||
status, headers, image_base64 = request.env['ir.http'].binary_content(
|
||||
xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename,
|
||||
@@ -1083,7 +1084,7 @@ class Binary(http.Controller):
|
||||
if not (width or height):
|
||||
width, height = odoo.tools.image_guess_size_from_field_name(field)
|
||||
|
||||
image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop)
|
||||
image_base64 = image_process(image_base64, size=(int(width), int(height)), crop=crop, quality=int(quality))
|
||||
|
||||
content = base64.b64decode(image_base64)
|
||||
headers = http.set_safe_image_headers(headers, content)
|
||||
|
||||
@@ -153,12 +153,11 @@ class Web_Editor(http.Controller):
|
||||
return True
|
||||
|
||||
@http.route('/web_editor/attachment/add_data', type='json', auth='user', methods=['POST'], website=True)
|
||||
def add_data(self, name, data, disable_optimization=False, res_id=False, res_model='ir.ui.view', filters=False, **kwargs):
|
||||
if not disable_optimization:
|
||||
try:
|
||||
data = tools.image_process(data, verify_resolution=True)
|
||||
except OSError:
|
||||
pass # not an image
|
||||
def add_data(self, name, data, quality=0, width=0, height=0, res_id=False, res_model='ir.ui.view', filters=False, **kwargs):
|
||||
try:
|
||||
data = tools.image_process(data, size=(width, height), quality=quality, verify_resolution=True)
|
||||
except OSError:
|
||||
pass # not an image
|
||||
attachment = self._attachment_create(name=name, data=data, res_id=res_id, res_model=res_model, filters=filters)
|
||||
return attachment._get_media_info()
|
||||
|
||||
@@ -167,6 +166,22 @@ class Web_Editor(http.Controller):
|
||||
attachment = self._attachment_create(url=url, res_id=res_id, res_model=res_model, filters=filters)
|
||||
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:
|
||||
attachment = attachment.copy()
|
||||
data = {}
|
||||
if name:
|
||||
data['name'] = name
|
||||
try:
|
||||
data['datas'] = tools.image_process(attachment.datas, size=(width, height), quality=quality)
|
||||
except OSError:
|
||||
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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from werkzeug import url_quote
|
||||
|
||||
from odoo import api, models, fields
|
||||
from odoo import api, models, fields, tools
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
@@ -12,6 +12,8 @@ class IrAttachment(models.Model):
|
||||
|
||||
local_url = fields.Char("Attachment URL", compute='_compute_local_url')
|
||||
image_src = fields.Char(compute='_compute_image_src')
|
||||
image_width = fields.Integer(compute='_compute_image_size')
|
||||
image_height = fields.Integer(compute='_compute_image_size')
|
||||
|
||||
@api.one
|
||||
def _compute_local_url(self):
|
||||
@@ -32,8 +34,20 @@ class IrAttachment(models.Model):
|
||||
url_quote(attachment.name or ''),
|
||||
)
|
||||
|
||||
@api.multi
|
||||
@api.depends('datas')
|
||||
def _compute_image_size(self):
|
||||
for attachment in self:
|
||||
try:
|
||||
image = tools.base64_to_image(attachment.datas)
|
||||
attachment.image_width = image.width
|
||||
attachment.image_height = image.height
|
||||
except Exception:
|
||||
attachment.image_width = 0
|
||||
attachment.image_height = 0
|
||||
|
||||
@api.multi
|
||||
def _get_media_info(self):
|
||||
"""Return a dict with the values that we need on the media dialog."""
|
||||
self.ensure_one()
|
||||
return self.read(['id', 'name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src'])[0]
|
||||
return self.read(['id', 'name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height'])[0]
|
||||
|
||||
@@ -61,7 +61,14 @@ var MediaPlugin = AbstractPlugin.extend({
|
||||
media = $mediaParent[0];
|
||||
$mediaParent = $mediaParent.parent();
|
||||
}
|
||||
|
||||
// If the image is for a field the model should handle the resize so we
|
||||
// use an arbitrary high size of 1920 instead of using the width of the
|
||||
// the element.
|
||||
var mediaWidth = $(media).parent().data('oeField') ? 1920 : $(media).width();
|
||||
|
||||
var mediaDialog = new weWidgets.MediaDialog(this.options.parent, {
|
||||
mediaWidth: mediaWidth,
|
||||
onlyImages: $mediaParent.data('oeField') === 'image' || $mediaParent.data('oeType') === 'image',
|
||||
},
|
||||
$(media).clone()[0]
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
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.isExisting = params.isExisting;
|
||||
this.attachment = params.attachment;
|
||||
// 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(params.optimizedWidth || this.attachment.image_width, this.attachment.image_width);
|
||||
this.defaultQuality = this.isExisting ? 100 : 80;
|
||||
this.defaultWidth = parseInt(this.isExisting ? this.attachment.image_width : this.optimizedWidth);
|
||||
this.defaultHeight = parseInt(this.isExisting || !this.attachment.image_width ? this.attachment.image_height :
|
||||
this.optimizedWidth / this.attachment.image_width * this.attachment.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.attachment.image_width,
|
||||
'text': _.str.sprintf(_t("%d (Original)"), this.attachment.image_width),
|
||||
});
|
||||
this.suggestedWidths = _.sortBy(this.suggestedWidths, 'width');
|
||||
this._updatePreview = _.debounce(this._updatePreview.bind(this), 300);
|
||||
},
|
||||
/**
|
||||
* @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.attachment.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.id, 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.attachment.image_width) {
|
||||
isValid = false;
|
||||
}
|
||||
if (height < 0 || height > this.attachment.image_height) {
|
||||
isValid = false;
|
||||
}
|
||||
return isValid;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onHeightInput: function () {
|
||||
var height = parseInt(this.$heightInput.val()) || 0;
|
||||
this.$widthInput
|
||||
.val(parseInt(height / this.attachment.image_height * this.attachment.image_width));
|
||||
this._updatePreview();
|
||||
this._validateForm();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onKeepOriginalClick: function () {
|
||||
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) {
|
||||
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.attachment.image_width * this.attachment.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,
|
||||
};
|
||||
});
|
||||
@@ -1,16 +1,17 @@
|
||||
odoo.define('wysiwyg.widgets.media', function (require) {
|
||||
'use strict';
|
||||
|
||||
var concurrency = require('web.concurrency');
|
||||
var config = require('web.config');
|
||||
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 concurrency = require('web.concurrency');
|
||||
|
||||
var QWeb = core.qweb;
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var MediaWidget = Widget.extend({
|
||||
@@ -104,13 +105,14 @@ var SearchableMediaWidget = MediaWidget.extend({
|
||||
var FileWidget = SearchableMediaWidget.extend({
|
||||
events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
|
||||
'click .o_upload_media_button': '_onUploadButtonClick',
|
||||
'click .o_upload_media_button_no_optimization': '_onUploadButtonNoOptimizationClick',
|
||||
'click .o_we_quick_upload': '_onQuickUploadClick',
|
||||
'change .o_file_input': '_onFileInputChange',
|
||||
'click .o_upload_media_url_button': '_onUploadURLButtonClick',
|
||||
'input .o_we_url_input': '_onURLInputChange',
|
||||
'click .o_existing_attachment_cell': '_onAttachmentClick',
|
||||
'dblclick .o_existing_attachment_cell': '_onAttachmentDblClick',
|
||||
'click .o_existing_attachment_remove': '_onRemoveClick',
|
||||
'click .o_existing_attachment_optimize': '_onExistingOptimizeClick',
|
||||
'click .o_load_more': '_onLoadMoreClick',
|
||||
}),
|
||||
existingAttachmentsTemplate: undefined,
|
||||
@@ -118,6 +120,14 @@ 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
|
||||
*/
|
||||
@@ -130,6 +140,7 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
this.options = _.extend({
|
||||
firstFilters: [],
|
||||
lastFilters: [],
|
||||
showQuickUpload: config.debug,
|
||||
}, options || {});
|
||||
|
||||
this.attachments = [];
|
||||
@@ -138,6 +149,8 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick);
|
||||
},
|
||||
/**
|
||||
* Loads all the existing images related to the target media.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
@@ -211,7 +224,7 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
args: [],
|
||||
kwargs: {
|
||||
domain: this._getAttachmentsDomain(needle),
|
||||
fields: ['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src'],
|
||||
fields: ['name', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height'],
|
||||
order: [{name: 'id', asc: false}],
|
||||
context: this.options.context,
|
||||
},
|
||||
@@ -257,6 +270,20 @@ 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
|
||||
@@ -334,6 +361,43 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
return this.search(this.$('.o_we_search').val() || '');
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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) {
|
||||
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');
|
||||
resolve(ev.data);
|
||||
});
|
||||
optimizeDialog.on('closed', self, function () {
|
||||
self.noSave = true;
|
||||
resolve(attachment);
|
||||
});
|
||||
});
|
||||
var always = function () {
|
||||
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,7 +510,7 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
*/
|
||||
_selectAttachement: function (attachment, save) {
|
||||
if (this.options.multiImages) {
|
||||
// if the clicked image is already selected then unselect it
|
||||
// if the clicked attachment is already selected then unselect it
|
||||
// unless it was a save request (then keep the current selection)
|
||||
var index = this.selectedAttachments.indexOf(attachment);
|
||||
if (index !== -1) {
|
||||
@@ -454,11 +518,11 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
this.selectedAttachments.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
// if the clicked image is not selected, add it to selection
|
||||
// if the clicked attachment is not selected, add it to selected
|
||||
this.selectedAttachments.push(attachment);
|
||||
}
|
||||
} else {
|
||||
// select the clicked image
|
||||
// select the clicked attachment
|
||||
this.selectedAttachments = [attachment];
|
||||
}
|
||||
this._highlightSelected();
|
||||
@@ -478,7 +542,6 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
this.$addUrlButton.toggleClass('btn-secondary', emptyValue)
|
||||
.toggleClass('btn-primary', !emptyValue)
|
||||
.prop('disabled', !isURL);
|
||||
|
||||
this.$urlSuccess.toggleClass('d-none', !isURL);
|
||||
this.$urlError.toggleClass('d-none', emptyValue || isURL);
|
||||
},
|
||||
@@ -502,6 +565,23 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
this._onAttachmentClick(ev, true);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onExistingOptimizeClick: function (ev) {
|
||||
var self = this;
|
||||
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).then(function (newAttachment) {
|
||||
self._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.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
@@ -520,6 +600,7 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
_addData: function () {
|
||||
var self = this;
|
||||
var uploadMutex = new concurrency.Mutex();
|
||||
var optimizeMutex = new concurrency.Mutex();
|
||||
|
||||
// Upload the smallest file first to block the user the least possible.
|
||||
var files = _.sortBy(this.$fileInput[0].files, 'size');
|
||||
@@ -529,27 +610,55 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
// limited by bandwidth.
|
||||
uploadMutex.exec(function () {
|
||||
return utils.getDataURLFromFile(file).then(function (result) {
|
||||
var params = {
|
||||
'name': file.name,
|
||||
'data': result.split(',')[1],
|
||||
'res_id': self.options.res_id,
|
||||
'res_model': self.options.res_model,
|
||||
'filters': self.options.firstFilters.join('_'),
|
||||
};
|
||||
if (self.quickUpload) {
|
||||
params['width'] = self._computeOptimizedWidth();
|
||||
params['quality'] = 80;
|
||||
} else {
|
||||
params['width'] = 0;
|
||||
params['quality'] = 0;
|
||||
}
|
||||
return self._rpc({
|
||||
route: '/web_editor/attachment/add_data',
|
||||
params: {
|
||||
'name': file.name,
|
||||
'data': result.split(',')[1],
|
||||
'res_id': self.options.res_id,
|
||||
'res_model': self.options.res_model,
|
||||
'filters': self.options.firstFilters.join('_'),
|
||||
},
|
||||
params: params,
|
||||
}).then(function (attachment) {
|
||||
self._handleNewAttachment(attachment);
|
||||
if (attachment.image_src && !self.quickUpload) {
|
||||
optimizeMutex.exec(function () {
|
||||
return self._openImageOptimizeDialog(attachment).then(function (updatedAttachment) {
|
||||
self._handleNewAttachment(updatedAttachment);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
self._handleNewAttachment(attachment);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return uploadMutex.getUnlockedDef().then(function () {
|
||||
if (!self.options.multiImages) {
|
||||
self.trigger_up('save_request');
|
||||
}
|
||||
return optimizeMutex.getUnlockedDef().then(function () {
|
||||
self.quickUpload = false;
|
||||
if (!self.options.multiImages && !self.noSave) {
|
||||
self.trigger_up('save_request');
|
||||
}
|
||||
self.noSave = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onQuickUploadClick: function () {
|
||||
this.quickUpload = true;
|
||||
this.$uploadButton.trigger('click');
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -588,7 +697,7 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
var emptyValue = (inputValue === '');
|
||||
|
||||
var isURL = /^.+\..+$/.test(inputValue); // TODO improve
|
||||
var isImage = _.any(['.gif', '.jpe', '.jpg', '.png'], function (format) {
|
||||
var isImage = _.any(['.gif', '.jpeg', '.jpe', '.jpg', '.png'], function (format) {
|
||||
return inputValue.endsWith(format);
|
||||
});
|
||||
|
||||
@@ -603,14 +712,6 @@ var FileWidget = SearchableMediaWidget.extend({
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onUploadButtonNoOptimizationClick: function () {
|
||||
this.$('input[name="disable_optimization"]').val('1');
|
||||
this.$uploadButton.click();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_onUploadURLButtonClick: function () {
|
||||
return this._mutex.exec(this._addUrl.bind(this));
|
||||
},
|
||||
|
||||
@@ -28,6 +28,8 @@ var MediaDialog = Dialog.extend({
|
||||
}),
|
||||
custom_events: _.extend({}, Dialog.prototype.custom_events || {}, {
|
||||
save_request: '_onSaveRequest',
|
||||
show_parent_dialog_request: '_onShowRequest',
|
||||
hide_parent_dialog_request: '_onHideRequest',
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -209,6 +211,18 @@ var MediaDialog = Dialog.extend({
|
||||
_onClickVideoTab: function () {
|
||||
this.activeWidget = this.videoWidget;
|
||||
},
|
||||
/**
|
||||
* Handles hide request from child widgets.
|
||||
*
|
||||
* This is for usability, to allow hiding the modal for example when another
|
||||
* smaller modal would be displayed on top.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onHideRequest: function (ev) {
|
||||
this.$modal.addClass('d-none');
|
||||
},
|
||||
/**
|
||||
* Handles save request from the child widgets.
|
||||
*
|
||||
@@ -222,6 +236,17 @@ var MediaDialog = Dialog.extend({
|
||||
ev.stopPropagation();
|
||||
this.save();
|
||||
},
|
||||
/**
|
||||
* Handles show request from the child widgets.
|
||||
*
|
||||
* This is for usability, it is the counterpart of @see _onHideRequest.
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onShowRequest: function (ev) {
|
||||
this.$modal.removeClass('d-none');
|
||||
},
|
||||
});
|
||||
|
||||
return MediaDialog;
|
||||
|
||||
@@ -853,6 +853,7 @@ registry.background = SnippetOption.extend({
|
||||
var $editable = this.$target.closest('.o_editable');
|
||||
var _editor = new weWidgets.MediaDialog(this, {
|
||||
onlyImages: true,
|
||||
mediaWidth: 1920,
|
||||
firstFilters: ['background'],
|
||||
res_model: $editable.data('oe-model'),
|
||||
res_id: $editable.data('oe-id'),
|
||||
|
||||
@@ -301,25 +301,32 @@ body .modal {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
|
||||
// prevent hover/select border from flickering
|
||||
border: 1px solid transparent;
|
||||
|
||||
.o_existing_attachment_remove {
|
||||
@include o-position-absolute(0, 0);
|
||||
.o_existing_attachment_optimize, .o_existing_attachment_remove {
|
||||
background-color: scale-color($card-border-color, $alpha: -50%);
|
||||
text-shadow: 0 0 1px white;
|
||||
border-radius: 0 0 0 2px;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $card-border-color;
|
||||
}
|
||||
}
|
||||
|
||||
.o_existing_attachment_optimize {
|
||||
@include o-position-absolute($top: 0, $left: 0);
|
||||
border-radius: 0 0 2px 0;
|
||||
}
|
||||
|
||||
.o_existing_attachment_remove {
|
||||
@include o-position-absolute($top: 0, $right: 0);
|
||||
border-radius: 0 0 0 2px;
|
||||
&:hover {
|
||||
color: $o-wysiwyg-color-danger;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.o_existing_attachment_remove {
|
||||
.o_existing_attachment_optimize, .o_existing_attachment_remove {
|
||||
opacity: 1;
|
||||
}
|
||||
&.o_we_attachment_highlight, .o_we_attachment_highlight {
|
||||
@@ -455,6 +462,16 @@ body .modal {
|
||||
border-left: 1px solid gray('200');
|
||||
}
|
||||
}
|
||||
|
||||
.o_we_image_optimize_dialog {
|
||||
.o_we_title_label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.o_we_preview_area {
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight selected image/icon
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
<t t-name="wysiwyg.widgets.files.submenu">
|
||||
<div class="form-inline align-items-center py-4">
|
||||
<input type="file" class="d-none o_file_input" name="upload" t-att-accept="widget.options.accept" t-att-multiple="widget.options.multiImages && 'multiple'"/>
|
||||
<input type="hidden" name="disable_optimization" value=""/>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary o_upload_media_button">
|
||||
@@ -145,13 +144,11 @@
|
||||
<t t-set="addText">Add image</t>
|
||||
<t t-set="searchPlaceholder">Search an image</t>
|
||||
<t t-set="urlWarningTitle">The URL does not contain any image. The file will be added in the document section.</t>
|
||||
<t t-set="alternateUpload">
|
||||
<button class="btn btn-primary dropdown-toggle dropdown-toggle-split o_media_optimization" data-toggle="dropdown" type="button">
|
||||
<span class="sr-only">Alternate Upload</span>
|
||||
<t t-if="widget.options.showQuickUpload" t-set="alternateUpload">
|
||||
<button class="btn btn-primary o_we_quick_upload" type="button" title="Quick Upload">
|
||||
<i class="fa fa-fast-forward"/>
|
||||
<span class="sr-only">Quick Upload</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<button type="button" role="menuitem" class="dropdown-item o_upload_media_button_no_optimization">Upload image without optimization</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
@@ -186,6 +183,65 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="wysiwyg.widgets.image.optimize">
|
||||
<form class="o_we_image_optimize_dialog">
|
||||
<div class="row">
|
||||
<div class="o_we_config_column col-lg-6">
|
||||
<div class="form-group">
|
||||
<label class="o_we_title_label" for="o_we_name_input">Name</label>
|
||||
<input type="text" class="form-control" id="o_we_name_input" name="filename" aria-describedby="nameHelp" t-att-value="widget.attachment.name" required="required"/>
|
||||
<small id="nameHelp" class="form-text text-muted">Give a relevant name to your file to optimize search engine results.</small>
|
||||
</div>
|
||||
|
||||
<small t-if="widget.disableResize" class="form-text text-muted o_we_no_resize"><span class="fa fa-info-circle"/> Resizing is not supported for images of type <t t-esc="widget.attachment.mimetype"/>.</small>
|
||||
<div class="form-group" t-else="1">
|
||||
<div>
|
||||
<div class="btn-group btn-group-sm mt-2" role="group" aria-label="Sizes">
|
||||
<t t-foreach="widget.suggestedWidths" t-as="suggestedWidth">
|
||||
<button t-attf-class="btn btn-link o_we_width_preset #{suggestedWidth.width === widget.attachment.image_width ? ' active' : ''}" t-att-data-width="suggestedWidth.width" t-esc="suggestedWidth.text"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<label class="o_we_title_label" for="o_we_width">Width (in px)</label>
|
||||
<input type="number" class="form-control" id="o_we_width" name="width" aria-describedby="sizeHelp" min="1" t-att-max="widget.attachment.image_width" t-att-value="widget.defaultWidth"/>
|
||||
<label class="o_we_title_label" for="o_we_height">Height (in px)</label>
|
||||
<input type="number" class="form-control" id="o_we_height" name="height" aria-describedby="sizeHelp" min="1" t-att-max="widget.attachment.image_height" t-att-value="widget.defaultHeight"/>
|
||||
<small id="sizeHelp" class="form-text text-muted">Reduce the size as much as possible to increase performance.</small>
|
||||
</div>
|
||||
|
||||
<small t-if="widget.disableQuality" class="form-text text-muted o_we_no_quality"><span class="fa fa-info-circle"/> Changing the quality is not supported for images of type <t t-esc="widget.attachment.mimetype"/>.</small>
|
||||
<div class="form-group" t-else="1">
|
||||
<t t-if="widget.toggleQuality">
|
||||
<div class="custom-control custom-switch">
|
||||
<input type="checkbox" class="custom-control-input" id="o_we_optimize_quality" t-att-checked="widget.isExisting ? undefined : 'checked'" aria-describedby="toggleQualityHelp"/>
|
||||
<label class="custom-control-label" for="o_we_optimize_quality">Optimize</label>
|
||||
</div>
|
||||
<small id="toggleQualityHelp" class="form-text text-muted">This reduces the quality to increase performance.</small>
|
||||
</t>
|
||||
<t t-else="1">
|
||||
<label class="o_we_title_label" for="o_we_quality_input">Quality</label>
|
||||
<div class="form-row">
|
||||
<div class="col-sm-10">
|
||||
<input type="range" class="custom-range o_we_quality_range" id="quality_range" name="quality_range" min="1" max="100" step="1" aria-describedby="rangeQualityHelp" t-att-value="widget.defaultQuality"/>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input type="number" class="form-control" id="o_we_quality_input" name="quality" min="1" max="100" step="1" aria-describedby="rangeQualityHelp" t-att-value="widget.defaultQuality"/>
|
||||
</div>
|
||||
</div>
|
||||
<small id="rangeQualityHelp" class="form-text text-muted">Reduce the quality as much as possible to increase performance.</small>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_we_preview_column col-lg-6">
|
||||
<h4>Preview with the current settings</h4>
|
||||
<div class="mw-100 o_we_preview_area">
|
||||
<img class="img o_we_preview_image" alt="Image Preview"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
|
||||
<t t-name="wysiwyg.widgets.image.existing.attachments">
|
||||
<div class="o_we_images">
|
||||
<ol class="existing-attachments d-flex flex-wrap mx-auto my-0 p-2">
|
||||
@@ -198,6 +254,7 @@
|
||||
|
||||
<t t-name="wysiwyg.widgets.image.existing.attachment">
|
||||
<li class="o_existing_attachment_cell position-relative m-2" t-att-data-id="attachment.id">
|
||||
<i t-if="!attachment.url" 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 h-100">
|
||||
<img class="img img-fluid o_we_attachment_highlight" t-att-src="attachment.image_src + '?width=256&height=192'" t-att-alt="attachment.name" t-att-title="attachment.name"/>
|
||||
|
||||
@@ -359,6 +359,14 @@ function wysiwygData(data) {
|
||||
string: "image_src",
|
||||
type: 'char',
|
||||
},
|
||||
image_width: {
|
||||
string: "image_width",
|
||||
type: 'integer',
|
||||
},
|
||||
image_height: {
|
||||
string: "image_height",
|
||||
type: 'integer',
|
||||
},
|
||||
},
|
||||
records: [{
|
||||
id: 1,
|
||||
@@ -373,6 +381,8 @@ function wysiwygData(data) {
|
||||
public: true,
|
||||
access_token: false,
|
||||
image_src: '/web_editor/static/src/img/transparent.png',
|
||||
image_width: 256,
|
||||
image_height: 256,
|
||||
}],
|
||||
generate_access_token: function () {
|
||||
return;
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
<!-- 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"/>
|
||||
|
||||
@@ -1001,7 +1001,7 @@ options.registry.gallery = options.Class.extend({
|
||||
addImages: function (previewMode) {
|
||||
var self = this;
|
||||
var $container = this.$('.container:first');
|
||||
var dialog = new weWidgets.MediaDialog(this, {multiImages: true, onlyImages: true}, null);
|
||||
var dialog = new weWidgets.MediaDialog(this, {multiImages: true, onlyImages: true, mediaWidth: 1920}, null);
|
||||
var lastImage = _.last(this._getImages());
|
||||
var index = lastImage ? this._getIndex(lastImage) : -1;
|
||||
dialog.on('save', this, function (attachments) {
|
||||
|
||||
@@ -222,12 +222,12 @@ var ThemeCustomizeDialog = Dialog.extend({
|
||||
var def = new Promise(function (resolve, reject) {
|
||||
var $image = $('<img/>');
|
||||
var editor = new weWidgets.MediaDialog(self, {
|
||||
mediaWidth: 1920,
|
||||
onlyImages: true,
|
||||
firstFilters: ['background'],
|
||||
}, $image[0]);
|
||||
|
||||
editor.on('save', self, function (media) { // TODO use scss customization instead (like for user colors)
|
||||
var src = $(media).attr('src');
|
||||
self._rpc({
|
||||
model: 'ir.model.data',
|
||||
method: 'get_object_reference',
|
||||
@@ -238,7 +238,7 @@ var ThemeCustomizeDialog = Dialog.extend({
|
||||
method: 'save',
|
||||
args: [
|
||||
data[1],
|
||||
'#wrapwrap { background-image: url("' + src + '"); }',
|
||||
'#wrapwrap { background-image: url("' + media.src + '"); }',
|
||||
'//style',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -179,6 +179,7 @@ options.registry.blog_cover = options.Class.extend({
|
||||
}
|
||||
|
||||
var editor = new weWidgets.MediaDialog(this, {
|
||||
mediaWidth: 1920,
|
||||
onlyImages: true,
|
||||
firstFilters: ['background']
|
||||
}, $image[0]).open();
|
||||
|
||||
Reference in New Issue
Block a user