diff options
| author | Samuel Degueldre <sad@odoo.com> | 2020-08-12 10:15:51 +0000 |
|---|---|---|
| committer | Samuel Degueldre <sad@odoo.com> | 2020-09-16 07:01:15 +0000 |
| commit | 7a1308b426f94b33e2bd55e5d58e661067e26ff3 (patch) | |
| tree | 651d7c12b8175c995247eb9a6b520e4faf46687e | |
| parent | a2cee3e630634004b8aeeb0869aedadf99913647 (diff) | |
[IMP] web_editor, *: make media-dialog search odoo media-library
*: website, web_unsplash, website_blog
We have recently created an odoo media library that exposes a public API
for searching for open-source media, including dynamic SVGs that can be
used with the dynamic background-shape system to create illustrations
whose colors can be changed by the user. This commit adds the
corresponding client-side functionalty: search for the images in the
media-library, creating a corresponding attachment when selected, as
well as an option to customize the colors of the dynamic SVGs.
In the process, adaptations have been made in the web_unsplash module to
avoid duplicating code and to adapt code that was making assumptions
that are no longer valid.
To make new users more aware of this feature, all default images have
been hidden from the media-dialog, so that the user is prompted so
search for images. Some tests and tours that were relying on having
default images in the media-dialog (eg in website_blog) have been
adapted to match.
task-2316651
Linked to: odoo/iap-apps#235
closes odoo/odoo#56479
Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
19 files changed, 552 insertions, 76 deletions
diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 5285af88c2c..e79751eeb79 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -4,18 +4,21 @@ import io import logging import re import time +import requests import werkzeug.wrappers from PIL import Image, ImageFont, ImageDraw from lxml import etree -from base64 import b64decode +from base64 import b64decode, b64encode from odoo.http import request -from odoo import http, tools, _ +from odoo import http, tools, _, SUPERUSER_ID +from odoo.addons.http_routing.models.ir_http import slug from odoo.exceptions import UserError -from odoo.modules.module import get_resource_path +from odoo.modules.module import get_module_path, get_resource_path +from odoo.tools.misc import file_open logger = logging.getLogger(__name__) - +DEFAULT_LIBRARY_ENDPOINT = 'https://media-api.odoo.com' class Web_Editor(http.Controller): #------------------------------------------------------ @@ -505,24 +508,30 @@ class Web_Editor(http.Controller): return '%s?access_token=%s' % (attachment.image_src, attachment.access_token) @http.route(['/web_editor/shape/<module>/<path:filename>'], type='http', auth="public", website=True) - def background_shape(self, module, filename, **kwargs): + def shape(self, module, filename, **kwargs): """ - Returns a color-customized background-shape. + Returns a color-customized svg (background shape or illustration). """ - shape_path = get_resource_path(module, 'static', 'shapes', filename) - if shape_path: - svg = open(shape_path, 'r').read() - else: - # Fallback to attachments for future migrations - attachment = request.env['ir.attachment'].search([('url', '=like', request.httprequest.path)], limit=1) + svg = None + if module == 'illustration': + attachment = request.env['ir.attachment'].sudo().search([('url', '=like', request.httprequest.path), ('public', '=', True)], limit=1) if not attachment: raise werkzeug.exceptions.NotFound() svg = b64decode(attachment.datas).decode('utf-8') + else: + shape_path = get_resource_path(module, 'static', 'shapes', filename) + if not shape_path: + raise werkzeug.exceptions.NotFound() + with tools.file_open(shape_path, 'r') as file: + svg = file.read() user_colors = [] for key, color in kwargs.items(): - match = re.match('^c([12345])$', key) + match = re.match('^c([1-5])$', key) if match: + # Check that color is hex or rgb(a) to prevent arbitrary injection + if not re.match(r'(?i)^#[0-9A-F]{6,8}$|^rgba?\(\d{1,3},\d{1,3},\d{1,3}(?:,[0-9.]{1,4})?\)$', color.replace(' ', '')): + raise werkzeug.exceptions.BadRequest() user_colors.append([tools.html_escape(color), match.group(1)]) default_palette = { @@ -545,3 +554,59 @@ class Web_Editor(http.Controller): ('Content-type', 'image/svg+xml'), ('Cache-control', 'max-age=%s' % http.STATIC_CACHE_LONG), ]) + + @http.route(['/web_editor/media_library_search'], type='json', auth="user", website=True) + def media_library_search(self, **params): + ICP = request.env['ir.config_parameter'].sudo() + endpoint = ICP.get_param('web_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT) + params['dbuuid'] = ICP.get_param('database.uuid') + response = requests.post('%s/media-library/1/search' % endpoint, data=params) + if response.status_code == requests.codes.ok and response.headers['content-type'] == 'application/json': + return response.json() + else: + return {'error': response.status_code} + + @http.route('/web_editor/save_library_media', type='json', auth='user', methods=['POST']) + def save_library_media(self, media): + """ + Saves images from the media library as new attachments, making them + dynamic SVGs if needed. + media = { + <media_id>: { + 'query': 'space separated search terms', + 'is_dynamic_svg': True/False, + }, ... + } + """ + attachments = [] + ICP = request.env['ir.config_parameter'].sudo() + library_endpoint = ICP.get_param('web_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT) + + media_ids = ','.join(media.keys()) + params = { + 'dbuuid': ICP.get_param('database.uuid'), + 'media_ids': media_ids, + } + response = requests.post('%s/media-library/1/download_urls' % library_endpoint, data=params) + if response.status_code != requests.codes.ok: + raise Exception(_("ERROR: couldn't get download urls from media library.")) + + for id, url in response.json().items(): + req = requests.get(url) + name = '_'.join([media[id]['query'], url.split('/')[-1]]) + # Need to bypass security check to write image with mimetype image/svg+xml + # ok because svgs come from whitelisted origin + context = {'binary_field_real_user': request.env['res.users'].sudo().browse([SUPERUSER_ID])} + attachment = request.env['ir.attachment'].sudo().with_context(context).create({ + 'name': name, + 'mimetype': req.headers['content-type'], + 'datas': b64encode(req.content), + 'public': True, + 'res_model': 'ir.ui.view', + 'res_id': 0, + }) + if media[id]['is_dynamic_svg']: + attachment['url'] = '/web_editor/shape/illustration/%s' % slug(attachment) + attachments.append(attachment._get_media_info()) + + return attachments diff --git a/addons/web_editor/static/src/img/curved_arrow.svg b/addons/web_editor/static/src/img/curved_arrow.svg new file mode 100644 index 00000000000..a8ed9acd242 --- /dev/null +++ b/addons/web_editor/static/src/img/curved_arrow.svg @@ -0,0 +1,14 @@ +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1144 1280"> +<g transform="translate(0,1280) scale(0.1,-0.1)" fill="#000000" stroke="none"> +<path d="M7166 11004 c-603 -901 -1094 -1639 -1092 -1641 1 -2 369 42 817 97 +448 55 825 101 838 103 30 3 26 14 86 -278 131 -634 218 -1317 255 -2010 13 +-246 13 -898 0 -1135 -71 -1262 -367 -2292 -883 -3065 -162 -242 -308 -418 +-541 -650 -661 -658 -1465 -1094 -2581 -1400 -1069 -293 -2283 -471 -3660 +-536 -132 -6 -242 -13 -244 -15 -6 -5 16 -190 30 -255 9 -43 13 -47 47 -53 +108 -17 1148 -2 1627 24 2760 152 4778 866 6094 2155 506 496 887 1018 1216 +1670 444 877 715 1860 819 2970 35 365 41 510 41 1000 0 581 -17 880 -76 1355 +-19 158 -65 452 -74 477 -5 14 -54 7 730 104 363 44 662 83 665 85 2 3 0 8 -6 +12 -6 4 -682 594 -1503 1312 -822 718 -1497 1306 -1501 1308 -4 2 -501 -734 +-1104 -1634z"/> +</g> +</svg> diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js index c66bb75a47c..6e92bd6955b 100644 --- a/addons/web_editor/static/src/js/common/utils.js +++ b/addons/web_editor/static/src/js/common/utils.js @@ -32,6 +32,18 @@ const CSS_UNITS_CONVERSION = { 'rem-px': () => _computePxByRem(), 'px-rem': () => _computePxByRem(true), }; +/** + * Colors of the default palette, used for substitution in shapes/illustrations. + * key: number of the color in the palette (ie, o-color-<1-5>) + * value: color hex code + */ +const DEFAULT_PALETTE = { + '1': '#3AADAA', + '2': '#7C6576', + '3': '#F6F6F6', + '4': '#FFFFFF', + '5': '#383E45', +}; /** * Computes the number of "px" needed to make a "rem" unit. Subsequent calls @@ -218,6 +230,7 @@ function _getBgImageURL(el) { return { CSS_SHORTHANDS: CSS_SHORTHANDS, CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION, + DEFAULT_PALETTE: DEFAULT_PALETTE, computePxByRem: _computePxByRem, convertValueToUnit: _convertValueToUnit, convertNumericToUnit: _convertNumericToUnit, diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index 4254afe270f..37eda9910d1 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -10,6 +10,7 @@ var options = require('web_editor.snippets.options'); var Wysiwyg = require('web_editor.wysiwyg'); const {ColorPaletteWidget} = require('web_editor.ColorPalette'); const SmoothScrollOnDrag = require('web/static/src/js/core/smooth_scroll_on_drag.js'); +const {getCSSVariableValue} = require('web_editor.utils'); var _t = core._t; @@ -1853,6 +1854,12 @@ var SnippetsMenu = Widget.extend({ } $toInsert = $baseBody.clone(); + // Color-customize dynamic SVGs in dropped snippets with current theme colors. + [...$toInsert.find('img[src^="/web_editor/shape/"]')].forEach(dynamicSvg => { + const colorCustomizedURL = new URL(dynamicSvg.getAttribute('src'), window.location.origin); + colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1')); + dynamicSvg.src = colorCustomizedURL.pathname + colorCustomizedURL.search; + }); if (!$selectorSiblings.length && !$selectorChildren.length) { console.warn($snippet.find('.oe_snippet_thumbnail_title').text() + " have not insert action: data-drop-near or data-drop-in"); diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 8e236bb93d1..1a0e8d34935 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -3578,6 +3578,30 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ this.$target.trigger('background_changed', [previewMode]); } }, + /** + * Changes the main color of dynamic SVGs. + * + * @see this.selectClass for parameters + */ + async dynamicColor(previewMode, widgetValue, params) { + const currentSrc = getBgImageURL(this.$target[0]); + switch (previewMode) { + case true: + this.previousSrc = currentSrc; + break; + case 'reset': + this.$target.css('background-image', `url('${this.previousSrc}')`); + return; + } + const newURL = new URL(currentSrc, window.location.origin); + newURL.searchParams.set('c1', normalizeColor(widgetValue)); + const src = newURL.pathname + newURL.search; + await loadImage(src); + this.$target.css('background-image', `url('${src}')`); + if (!previewMode) { + this.previousSrc = src; + } + }, //-------------------------------------------------------------------------- // Public @@ -3608,8 +3632,21 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ * @override */ _computeWidgetState: function (methodName) { - if (methodName === 'background') { - return getBgImageURL(this.$target[0]); + switch (methodName) { + case 'background': + return getBgImageURL(this.$target[0]); + case 'dynamicColor': + return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get('c1'); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'dynamic_color_opt') { + const src = new URL(getBgImageURL(this.$target[0]), window.location.origin); + return src.origin === window.location.origin && src.pathname.startsWith('/web_editor/shape/'); } return this._super(...arguments); }, @@ -4505,6 +4542,86 @@ registry.SnippetSave = SnippetOptionWidget.extend({ }, }); +/** + * Handles the dynamic colors for dynamic SVGs. + */ +registry.DynamicSvg = SnippetOptionWidget.extend({ + /** + * @override + */ + start() { + this.$target.on('image_changed.DynamicSvg', this._onImageChanged.bind(this)); + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this.$target.off('.DynamicSvg'); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Sets the dynamic SVG's dynamic color. + * + * @see this.selectClass for params + */ + async color(previewMode, widgetValue, params) { + const target = this.$target[0]; + switch (previewMode) { + case true: + this.previousSrc = target.getAttribute('src'); + break; + case 'reset': + target.src = this.previousSrc; + return; + } + const newURL = new URL(target.src, window.location.origin); + newURL.searchParams.set('c1', normalizeColor(widgetValue)); + const src = newURL.pathname + newURL.search; + await loadImage(src); + target.src = src; + if (!previewMode) { + this.previousSrc = src; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + switch (methodName) { + case 'color': + return new URL(this.$target[0].src, window.location.origin).searchParams.get('c1'); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeVisibility(methodName, params) { + return this.$target.is("img[src^='/web_editor/shape/']"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onImageChanged(methodName, params) { + return this.updateUI(); + }, +}); return { SnippetOptionWidget: SnippetOptionWidget, diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js index d405d647ce7..3134f55b373 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -10,6 +10,7 @@ var utils = require('web.utils'); var Widget = require('web.Widget'); var session = require('web.session'); const {removeOnImageChangeAttrs} = require('web_editor.image_processing'); +const {getCSSVariableValue, DEFAULT_PALETTE} = require('web_editor.utils'); var QWeb = core.qweb; var _t = core._t; @@ -106,6 +107,7 @@ var SearchableMediaWidget = MediaWidget.extend({ * @private */ _onSearchInput: function (ev) { + this.attachments = []; this.search($(ev.currentTarget).val() || '').then(() => this._renderThumbnails()); this.hasSearched = true; }, @@ -128,6 +130,7 @@ 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, + MAX_DB_ATTACHMENTS: 5, /** * @constructor @@ -144,6 +147,8 @@ var FileWidget = SearchableMediaWidget.extend({ this.attachments = []; this.selectedAttachments = []; + this.libraryMedia = []; + this.selectedMedia = []; this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick); }, @@ -204,7 +209,6 @@ var FileWidget = SearchableMediaWidget.extend({ * @override */ search: function (needle) { - this.attachments = []; this.needle = needle; return this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0); }, @@ -231,6 +235,12 @@ var FileWidget = SearchableMediaWidget.extend({ Array.prototype.splice.apply(this.attachments, [offset, attachments.length].concat(attachments)); }); }, + /** + * Computes whether there is content to display in the template. + */ + hasContent() { + return this.attachments.length; + }, //-------------------------------------------------------------------------- // Private @@ -292,7 +302,7 @@ var FileWidget = SearchableMediaWidget.extend({ domain.push(['name', 'ilike', needle]); } domain.push('!', ['name', '=like', '%.crop']); - domain.push('|', ['type', '=', 'binary'], ['url', '!=', false]); + domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']); return domain; }, /** @@ -357,6 +367,7 @@ var FileWidget = SearchableMediaWidget.extend({ this._highlightSelected(); // adapt load more + this.$('.o_we_load_more').toggleClass('d-none', !this.hasContent()); var noLoadMoreButton = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY >= this.attachments.length; var noMoreImgToLoad = this.numberOfAttachmentsToDisplay >= this.attachments.length; this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad); @@ -367,11 +378,36 @@ var FileWidget = SearchableMediaWidget.extend({ * @returns {Promise} */ _save: async function () { + // Create all media-library attachments. + const toSave = Object.fromEntries(this.selectedMedia.map(media => [ + media.id, { + query: media.query || '', + is_dynamic_svg: !!media.isDynamicSVG, + } + ])); + let mediaAttachments = []; + if (Object.keys(toSave).length !== 0) { + mediaAttachments = await this._rpc({ + route: '/web_editor/save_library_media', + params: { + media: toSave, + }, + }); + } + const selected = this.selectedAttachments.concat(mediaAttachments).map(attachment => { + // Color-customize dynamic SVGs with the primary theme color + if (attachment.image_src.startsWith('/web_editor/shape/')) { + const colorCustomizedURL = new URL(attachment.image_src, window.location.origin); + colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1')); + attachment.image_src = colorCustomizedURL.pathname + colorCustomizedURL.search; + } + return attachment; + }); if (this.options.multiImages) { - return this.selectedAttachments; + return selected; } - var img = this.selectedAttachments[0]; + const img = selected[0]; if (!img || !img.id) { return this.media; } @@ -435,22 +471,30 @@ var FileWidget = SearchableMediaWidget.extend({ * and to close the media dialog * @private */ - _selectAttachement: function (attachment, save) { + _selectAttachement: function (attachment, save, {type = 'attachment'} = {}) { + const possibleProps = { + 'attachment': 'selectedAttachments', + 'media': 'selectedMedia' + }; + const prop = possibleProps[type]; if (this.options.multiImages) { // 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); + const index = this[prop].indexOf(attachment); if (index !== -1) { if (!save) { - this.selectedAttachments.splice(index, 1); + this[prop].splice(index, 1); } } else { // if the clicked attachment is not selected, add it to selected - this.selectedAttachments.push(attachment); + this[prop].push(attachment); } } else { + Object.values(possibleProps).forEach(prop => { + this[prop] = []; + }); // select the clicked attachment - this.selectedAttachments = [attachment]; + this[prop] = [attachment]; } this._highlightSelected(); if (save) { @@ -481,9 +525,15 @@ var FileWidget = SearchableMediaWidget.extend({ * @private */ _onAttachmentClick: function (ev) { - var $attachment = $(ev.currentTarget); - var attachment = _.find(this.attachments, {id: $attachment.data('id')}); - this._selectAttachement(attachment, !this.options.multiImages); + const attachment = ev.currentTarget; + const {id: attachmentID, mediaId} = attachment.dataset; + if (attachmentID) { + const attachment = this.attachments.find(attachment => attachment.id === parseInt(attachmentID)); + this._selectAttachement(attachment, !this.options.multiImages); + } else if (mediaId) { + const media = this.libraryMedia.find(media => media.id === parseInt(mediaId)); + this._selectAttachement(media, !this.options.multiImages, {type: 'media'}); + } }, /** * Handles change of the file input: create attachments with the new files @@ -639,6 +689,7 @@ var FileWidget = SearchableMediaWidget.extend({ * @override */ _onSearchInput: function () { + this.attachments = []; this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY; this._super.apply(this, arguments); }, @@ -652,6 +703,7 @@ var ImageWidget = FileWidget.extend({ existingAttachmentsTemplate: 'wysiwyg.widgets.image.existing.attachments', events: Object.assign({}, FileWidget.prototype.events, { 'change input.o_we_show_optimized': '_onShowOptimizedChange', + 'change .o_we_search_select': '_onSearchSelect', }), MIN_ROW_HEIGHT: 128, @@ -659,6 +711,7 @@ var ImageWidget = FileWidget.extend({ * @constructor */ init: function (parent, media, options) { + this.searchService = 'all'; options = _.extend({ accept: 'image/*', mimetypeDomain: [['mimetype', 'in', this.IMAGE_MIMETYPES]], @@ -681,6 +734,60 @@ var ImageWidget = FileWidget.extend({ this.el.removeEventListener('load', this._onAttachmentImageLoad, true); return this._super(...arguments); }, + /** + * @override + */ + async fetchAttachments(number, offset) { + if (this.needle && this.searchService !== 'database') { + number = this.MAX_DB_ATTACHMENTS; + offset = 0; + } + const result = await this._super(number, offset); + // Color-substitution for dynamic SVG attachment + const primaryColor = getCSSVariableValue('o-color-1'); + this.attachments.forEach(attachment => { + if (attachment.image_src.startsWith('/')) { + const newURL = new URL(attachment.image_src, window.location.origin); + // Set the main color of dynamic SVGs to o-color-1 + if (attachment.image_src.startsWith('/web_editor/shape/')) { + newURL.searchParams.set('c1', primaryColor); + } else { + // Set height so that db images load faster + newURL.searchParams.set('height', 2 * this.MIN_ROW_HEIGHT); + } + attachment.thumbnail_src = newURL.pathname + newURL.search; + } + }); + if (this.needle) { + try { + const response = await this._rpc({ + route: '/web_editor/media_library_search', + params: { + 'query': this.needle, + 'offset': this.libraryMedia.length, + }, + }); + const newMedia = response.media; + this.nbMediaResults = response.results; + this.libraryMedia.push(...newMedia); + } catch (e) { + // Either API endpoint doesn't exist or is misconfigured. + console.error(`Couldn't reach API endpoint.`); + } + } + return result; + }, + /** + * @override + */ + hasContent() { + if (this.searchService === 'all') { + return this._super(...arguments) || this.libraryMedia.length; + } else if (this.searchService === 'media-library') { + return !!this.libraryMedia.length; + } + return this._super(...arguments); + }, //-------------------------------------------------------------------------- // Private @@ -708,7 +815,7 @@ var ImageWidget = FileWidget.extend({ this.$('.o_existing_attachment_cell').addClass('d-none'); // Replace images that had been previously loaded if any to prevent scroll resetting to top alreadyLoaded.each((index, el) => { - const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"]`); + const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"], .o_existing_attachment_cell[data-media-id="${el.dataset.mediaId}"]`); if (toReplace.length) { toReplace.replaceWith(el); } @@ -720,13 +827,23 @@ var ImageWidget = FileWidget.extend({ flexGrow: placeholderWidth, flexBasis: placeholderWidth, }); + if (this.needle && ['media-library', 'all'].includes(this.searchService)) { + const noMoreImgToLoad = this.libraryMedia.length === this.nbMediaResults; + const noLoadMoreButton = noMoreImgToLoad && this.libraryMedia.length <= 15; + this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad); + this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad); + } }, /** * @override */ _renderExisting: function (attachments) { + if (this.needle && this.searchService !== 'database') { + attachments = attachments.slice(0, this.MAX_DB_ATTACHMENTS); + } return QWeb.render(this.existingAttachmentsTemplate, { attachments: attachments, + libraryMedia: this.libraryMedia, widget: this, }); }, @@ -738,6 +855,16 @@ var ImageWidget = FileWidget.extend({ _toggleOptimized: function (value) { this.$('.o_we_attachment_optimized').each((i, cell) => cell.style.setProperty('display', value ? null : 'none', 'important')); }, + /** + * @override + */ + _highlightSelected: function () { + this._super(...arguments); + this.selectedMedia.forEach(media => { + this.$(`.o_existing_attachment_cell[data-media-id=${media.id}]`) + .addClass("o_we_attachment_selected"); + }); + }, //-------------------------------------------------------------------------- // Handlers @@ -746,12 +873,37 @@ var ImageWidget = FileWidget.extend({ /** * @override */ - _onAttachmentImageLoad: function (ev) { + _onAttachmentImageLoad: async function (ev) { const img = ev.target; const cell = img.closest('.o_existing_attachment_cell'); if (!cell) { return; } + if (cell.dataset.mediaId && !img.src.startsWith('blob')) { + const mediaUrl = img.src; + try { + const response = await fetch(mediaUrl); + if (response.headers.get('content-type') === 'image/svg+xml') { + const svg = await response.text(); + const colorRegex = new RegExp(DEFAULT_PALETTE['1'], 'gi'); + if (colorRegex.test(svg)) { + const fileName = mediaUrl.split('/').pop(); + const file = new File([svg.replace(colorRegex, getCSSVariableValue('o-color-1'))], fileName, { + type: "image/svg+xml", + }); + img.src = URL.createObjectURL(file); + const media = this.libraryMedia.find(media => media.id === parseInt(cell.dataset.mediaId)); + if (media) { + media.isDynamicSVG = true; + } + // We changed the src: wait for the next load event to do the styling + return; + } + } + } catch (e) { + console.error('CORS is misconfigured on the API server, image will be treated as non-dynamic.'); + } + } const aspectRatio = img.naturalWidth / img.naturalHeight; const width = aspectRatio * this.MIN_ROW_HEIGHT; cell.style.flexGrow = width; @@ -766,6 +918,21 @@ var ImageWidget = FileWidget.extend({ _onShowOptimizedChange: function (ev) { this._toggleOptimized(ev.target.checked); }, + /** + * @override + */ + _onSearchSelect: function (ev) { + const {value} = ev.target; + this.searchService = value; + this.$('.o_we_search').trigger('input'); + }, + /** + * @private + */ + _onSearchInput: function (ev) { + this.libraryMedia = []; + this._super(...arguments); + }, }); diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss index 0cd22143290..ea7aee164e6 100644 --- a/addons/web_editor/static/src/scss/web_editor.common.scss +++ b/addons/web_editor/static/src/scss/web_editor.common.scss @@ -469,6 +469,31 @@ a.o_underline { } } +.o_we_search_prompt { + position: relative; + min-height: 250px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + + & > h2 { + max-width: 500px; + text-align: center; + } + + &::before { + content: ""; + @include o-position-absolute($top: 0, $right: 50px); + width: 100px; + height: 150px; + opacity: .5; + background-image: url('/web_editor/static/src/img/curved_arrow.svg'); + background-size: 100%; + background-repeat: no-repeat; + } +} + @include media-breakpoint-down(sm) { odoo-wysiwyg-container { .panel-heading.note-toolbar { diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss index 6eaa65c6e56..39fa326c737 100644 --- a/addons/web_editor/static/src/scss/wysiwyg.scss +++ b/addons/web_editor/static/src/scss/wysiwyg.scss @@ -394,6 +394,15 @@ img.o_we_selected_image { img::selection { background: transparent; } +.o_we_media_author { + font-size: 11px; + @include o-position-absolute($bottom: 0, $left: 0, $right: 0); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + background-color: rgba(255, 255, 255, .7); +} @include media-breakpoint-down(md) { #web_editor-top-edit { diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml index 714d0913e5d..96823fde263 100644 --- a/addons/web_editor/static/src/xml/wysiwyg.xml +++ b/addons/web_editor/static/src/xml/wysiwyg.xml @@ -125,7 +125,7 @@ <t t-call="wysiwyg.widgets.files.submenu"/> <div class="form-text o_we_error_text"/> <div class="o_we_existing_attachments"/> - <div class="mt-4 text-center mx-auto"> + <div class="mt-4 text-center mx-auto o_we_load_more"> <button class="btn btn-primary o_load_more d-none" type="button">Load more...</button> <div class="mt-4 o_load_done_msg d-none"> <span><i>All images have been loaded</i></span> @@ -170,12 +170,17 @@ <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> <div class="d-flex justify-content-end flex-grow-1 pr-3"> - <div t-attf-class="custom-control custom-switch #{__debug__ or 'd-none'}"> + <div t-attf-class="custom-control custom-switch #{__debug__ and 'd-flex' or 'd-none'} align-items-center mr-2"> <input class="o_we_show_optimized ml-2 custom-control-input" type="checkbox" id="o_we_show_optimized_switch"/> <label class="custom-control-label" for="o_we_show_optimized_switch"> Show optimized images </label> </div> + <select class="custom-select o_we_search_select"> + <option value="all">All</option> + <option value="database">My Images</option> + <option value="media-library">Illustrations</option> + </select> </div> </t> </t> @@ -288,14 +293,22 @@ <t t-name="wysiwyg.widgets.image.existing.attachments"> <div class="o_we_existing_attachments o_we_images d-flex flex-wrap w-100 justify-content-between align-items-stretch my-0"> - <div t-if="!attachments.length" class="o_nocontent_help"> - <p class="o_empty_folder_image">No images found.</p> - <p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p> - </div> + <t t-if="!widget.hasContent()"> + <div t-if="widget.needle" class="o_nocontent_help"> + <p class="o_empty_folder_image">No images found.</p> + <p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p> + </div> + <div t-else="" class="o_we_search_prompt"> + <h2>Get the perfect image by searching in our library of copyright free photos and illustrations.</h2> + </div> + </t> <t t-else=""> - <t t-foreach="attachments" t-as="attachment"> + <t t-if="['all', 'database'].includes(widget.searchService)" t-foreach="attachments" t-as="attachment"> <t t-call="wysiwyg.widgets.image.existing.attachment"/> </t> + <t t-if="['all', 'media-library'].includes(widget.searchService)" t-foreach="libraryMedia" t-as="media"> + <t t-call="wysiwyg.widgets.image.library_media"/> + </t> <!-- 20 placeholders is just enough for a 5K screen, change this if ImageWidget.MIN_ROW_HEIGHT changes --> <t t-foreach="20"> <div class="o_we_attachment_placeholder"/> @@ -309,13 +322,21 @@ <div t-attf-class="o_existing_attachment_cell position-relative bg-light #{isOptimized and 'o_we_attachment_optimized d-none' or ''} align-items-center justify-content-center" t-att-data-id="attachment.id"> <t t-call="wysiwyg.widgets.file.existing.remove"/> <div class="o_we_media_dialog_img_wrapper"> - <!-- 256 is 2 * ImageWidget.MIN_ROW_HEIGHT, hopefully the highest display width of any image --> - <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{attachment.image_src}?height=256" t-att-alt="attachment.name" t-att-title="attachment.name"/> + <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{attachment.thumbnail_src or attachment.image_src}" t-att-alt="attachment.name" t-att-title="attachment.name"/> </div> <span t-if="isOptimized" class="badge badge-success">Optimized</span> </div> </t> + <t t-name="wysiwyg.widgets.image.library_media"> + <div t-attf-class="o_existing_attachment_cell position-relative bg-light align-items-center justify-content-center o_library_media_cell" t-att-data-media-id="media.id"> + <div class="o_we_media_dialog_img_wrapper"> + <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{media.thumbnail_url}" t-att-title="media.tooltip or ''" crossorigin="anonymous"/> + <a t-if="media.author" class="o_we_media_author" t-att-href="media.author_link" target="_blank" t-esc="media.author"/> + </div> + </div> + </t> + <t t-name="wysiwyg.widgets.document.existing.attachments"> <div class="o_we_existing_attachments o_we_documents"> <div t-if="!attachments.length" class="o_nocontent_help"> diff --git a/addons/web_editor/static/tests/field_html_tests.js b/addons/web_editor/static/tests/field_html_tests.js index 21c54266fae..e24ae434907 100644 --- a/addons/web_editor/static/tests/field_html_tests.js +++ b/addons/web_editor/static/tests/field_html_tests.js @@ -261,12 +261,15 @@ QUnit.module('web_editor', {}, function () { return Promise.resolve(); } } - if (route.indexOf('/web/static/src/img/transparent.png') === 0) { + if (route.indexOf('/web/image/123/transparent.png') === 0) { return Promise.resolve(); } if (route.indexOf('/web_unsplash/fetch_images') === 0) { return Promise.resolve(); } + if (route.indexOf('/web_editor/media_library_search') === 0) { + return Promise.resolve(); + } return this._super(route, args); }, }); @@ -289,10 +292,11 @@ QUnit.module('web_editor', {}, function () { // load static xml file (dialog, media dialog, unsplash image widget) await defMediaDialog; + await testUtils.dom.click($('.modal #editor-media-image .o_existing_attachment_cell:first').removeClass('d-none')); var $editable = form.$('.oe_form_field[name="body"] .note-editable'); - assert.ok($editable.find('img')[0].dataset.src.includes('/web/static/src/img/transparent.png'), + assert.ok($editable.find('img')[0].dataset.src.includes('/web/image/123/transparent.png'), "should have the image in the dom"); testUtils.mock.unpatch(MediaDialog); diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js index 59b1c38e38b..1aa4a79c573 100644 --- a/addons/web_editor/static/tests/test_utils.js +++ b/addons/web_editor/static/tests/test_utils.js @@ -269,13 +269,13 @@ function wysiwygData(data) { description: '', mimetype: 'image/png', checksum: false, - url: '/web/static/src/img/transparent.png', + url: '/web/image/123/transparent.png', type: 'url', res_id: 0, res_model: false, public: true, access_token: false, - image_src: '/web/static/src/img/transparent.png', + image_src: '/web/image/123/transparent.png', image_width: 256, image_height: 256, }], diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml index 77118919f76..1dd5897f946 100644 --- a/addons/web_editor/views/snippets.xml +++ b/addons/web_editor/views/snippets.xml @@ -170,6 +170,7 @@ data-name="bg_image_opt" data-dependencies="bg_image_opt"/> </we-row> + <we-colorpicker string=" ⌙ Dynamic color" data-dynamic-color="true" data-name="dynamic_color_opt"/> </div> <div data-js="BackgroundPosition" @@ -357,8 +358,12 @@ <div data-js="ImageOptimize" data-selector="img"> - <t t-call="web_editor.snippet_options_image_optimization_widgets"/> - </div> + <t t-call="web_editor.snippet_options_image_optimization_widgets"/> + </div> + + <div data-js="DynamicSvg" data-selector="img"> + <we-colorpicker string="Dynamic color" data-color="true"/> + </div> </template> <!-- default block --> diff --git a/addons/web_unsplash/static/src/js/unsplash_image_widget.js b/addons/web_unsplash/static/src/js/unsplash_image_widget.js index 229283b00c4..e638806dc1c 100644 --- a/addons/web_unsplash/static/src/js/unsplash_image_widget.js +++ b/addons/web_unsplash/static/src/js/unsplash_image_widget.js @@ -27,8 +27,6 @@ widgetsMedia.ImageWidget.include({ 'click .o_unsplash_attachment_cell[data-imgid]': '_onUnsplashImgClick', 'click button.save_unsplash': '_onSaveUnsplashCredentials', }), - // Max number of database attachments to show when searching. - maxDbAttachments: 5, /** * @override @@ -103,10 +101,11 @@ widgetsMedia.ImageWidget.include({ */ search: async function (needle) { var self = this; - await this._super(needle, true); + await this._super(...arguments); this._unsplash.query = needle; if (!needle) { + this._unsplash.records = []; return; } @@ -118,6 +117,17 @@ widgetsMedia.ImageWidget.include({ self._unsplash.error = err; }); }, + /** + * @override + */ + hasContent() { + if (this.searchService === 'all') { + return this._super(...arguments) || (this.unsplashRecords && this.unsplashRecords.length); + } else if (this.searchService === 'unsplash') { + return (this.unsplashRecords && this.unsplashRecords.length); + } + return this._super(...arguments); + }, //-------------------------------------------------------------------------- // Private @@ -149,6 +159,10 @@ widgetsMedia.ImageWidget.include({ */ _renderThumbnails: function () { this._super(...arguments); + this.$('.unsplash_error').empty(); + if (!['all', 'unsplash'].includes(this.searchService)) { + return; + } if (this._unsplash.query && this._unsplash.error) { this.$('.unsplash_error').html( core.qweb.render('web_unsplash.dialog.error.content', { @@ -157,9 +171,8 @@ widgetsMedia.ImageWidget.include({ ); return; } - this.$('.unsplash_error').empty(); - if (this._unsplash.query && !this._unsplash.isMaxed) { + if (['all', 'unsplash'].includes(this.searchService) && this._unsplash.query && !this._unsplash.isMaxed) { this.$('.o_load_more').removeClass('d-none'); this.$('.o_load_done_msg').addClass('d-none'); } @@ -168,10 +181,7 @@ widgetsMedia.ImageWidget.include({ * @override */ _renderExisting: function (attachments) { - if (this._unsplash.records.length) { - attachments.splice(this.maxDbAttachments); - } - return this._super(attachments.concat(this._unsplash.records.map(record => { + this.unsplashRecords = this._unsplash.records.map(record => { const url = new URL(record.urls.regular); // In small windows, row height could get quite a bit larger than the min, so we keep some leeway. url.searchParams.set('h', 2 * this.MIN_ROW_HEIGHT); @@ -179,7 +189,8 @@ widgetsMedia.ImageWidget.include({ return Object.assign({}, record, { url: url.toString(), }); - }))); + }); + return this._super(...arguments); }, /** * @override diff --git a/addons/web_unsplash/static/src/scss/unsplash.scss b/addons/web_unsplash/static/src/scss/unsplash.scss index 340b21581ec..9e0267c214d 100644 --- a/addons/web_unsplash/static/src/scss/unsplash.scss +++ b/addons/web_unsplash/static/src/scss/unsplash.scss @@ -1,14 +1,3 @@ -.o_we_unplash_author { - font-size: 11px; - bottom: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - width: 100%; - position: absolute; - text-align: center; - background-color: #FFFB; -} .unsplash_error { padding: 30px 0; .access_key_box { diff --git a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml index b27a098c303..092f12038bd 100644 --- a/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml +++ b/addons/web_unsplash/static/src/xml/unsplash_image_widget.xml @@ -2,25 +2,27 @@ <templates id="template" xml:space="preserve"> <t t-extend="wysiwyg.widgets.file"> - <t t-jquery=".o_we_existing_attachments" t-operation="after"> + <t t-jquery=".o_we_load_more" t-operation="after"> <div class="unsplash_error"></div> </t> </t> +<t t-extend="wysiwyg.widgets.image"> + <t t-jquery="option[value='media-library']" t-operation="after"> + <option value="unsplash">Photos (via Unsplash)</option> + </t> +</t> <t t-extend="wysiwyg.widgets.image.existing.attachments"> - <t t-jquery="t[t-call]" t-operation="replace"> - <t t-if="typeof(attachment.id) === 'number'"> - <t t-call="wysiwyg.widgets.image.existing.attachment"/> - </t> - <t t-else=""> + <t t-jquery="t[t-foreach='libraryMedia']" t-operation="after"> + <t t-if="['all', 'unsplash'].includes(widget.searchService)" t-foreach="widget.unsplashRecords" t-as="record"> <t t-call="web_unsplash.dialog.image.content"/> </t> </t> </t> <t t-name="web_unsplash.dialog.image.content"> - <div class="o_existing_attachment_cell o_unsplash_attachment_cell position-relative align-items-center justify-content-center bg-light" t-att-data-imgid="attachment.id" t-att-data-id="attachment.id" t-att-data-url="attachment.urls.regular" t-att-data-download-url="attachment.links.download_location" t-att-data-description="attachment.alt_description"> - <img class="img img-fluid o_we_attachment_highlight" t-att-src="attachment.url" t-att-alt="attachment.alt_description" style="max-height: 100%;"/> - <a class="o_we_unplash_author" t-att-href="attachment.user.links.html" target="_blank" t-esc="attachment.user.name" t-att-title="attachment.user.name"/> + <div class="o_existing_attachment_cell o_unsplash_attachment_cell position-relative align-items-center justify-content-center bg-light" t-att-data-imgid="record.id" t-att-data-id="record.id" t-att-data-url="record.urls.regular" t-att-data-download-url="record.links.download_location" t-att-data-description="record.alt_description"> + <img class="img img-fluid o_we_attachment_highlight" t-att-src="record.url" t-att-alt="record.alt_description" style="max-height: 100%;"/> + <a class="o_we_media_author" t-att-href="record.user.links.html" target="_blank" t-esc="record.user.name" t-att-title="record.user.name"/> </div> </t> diff --git a/addons/website/static/src/js/widgets/media.js b/addons/website/static/src/js/widgets/media.js new file mode 100644 index 00000000000..900f53068ba --- /dev/null +++ b/addons/website/static/src/js/widgets/media.js @@ -0,0 +1,14 @@ +odoo.define('website.widgets.media', function (require) { +'use strict'; + +const {ImageWidget} = require('wysiwyg.widgets.media'); + +ImageWidget.include({ + _getAttachmentsDomain() { + const domain = this._super(...arguments); + domain.push('|', ['url', '=', false], '!', ['url', '=like', '/web/image/website.%']); + domain.push(['key', '=', false]); + return domain; + } +}); +}); diff --git a/addons/website/views/assets.xml b/addons/website/views/assets.xml index f59e696a9ef..0082f824c08 100644 --- a/addons/website/views/assets.xml +++ b/addons/website/views/assets.xml @@ -142,6 +142,8 @@ <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone.js"/> <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone_translate.js"/> <script type="text/javascript" src="/website/static/src/js/editor/widget_link.js"/> + + <script type="text/javascript" src="/website/static/src/js/widgets/media.js"/> </template> <template id="website.assets_editor" name="Website Editor Assets (used in website editor)"> diff --git a/addons/website_blog/static/src/js/tours/website_blog.js b/addons/website_blog/static/src/js/tours/website_blog.js index a2be1051ccf..6f071fe178f 100644 --- a/addons/website_blog/static/src/js/tours/website_blog.js +++ b/addons/website_blog/static/src/js/tours/website_blog.js @@ -32,8 +32,12 @@ odoo.define("website_blog.tour", function (require) { content: _t("Set a blog post <b>cover</b>."), position: "right", }, { - trigger: ".o_select_media_dialog .o_existing_attachment_cell:nth(1) img", - extra_trigger: '.modal:has(.o_existing_attachment_cell:nth(1))', + trigger: ".o_select_media_dialog .o_we_search", + content: _t("Search for an image. (eg: type \"business\")"), + position: "top", + }, { + trigger: ".o_select_media_dialog .o_existing_attachment_cell:first img", + extra_trigger: '.modal:has(.o_existing_attachment_cell:first)', content: _t("Choose an image from the library."), position: "top", }, { diff --git a/addons/website_blog/tests/test_ui.py b/addons/website_blog/tests/test_ui.py index 9a2f7aad09f..f4c7b003f8e 100644 --- a/addons/website_blog/tests/test_ui.py +++ b/addons/website_blog/tests/test_ui.py @@ -9,4 +9,11 @@ from odoo import tools class TestUi(odoo.tests.HttpCase): def test_admin(self): self.env['blog.blog'].create({'name': 'Travel'}) + self.env['ir.attachment'].create({ + 'public': True, + 'type': 'url', + 'url': '/web/image/123/transparent.png', + 'name': 'transparent.png', + 'mimetype': 'image/png', + }) self.start_tour("/", 'blog', login='admin') |
