diff --git a/addons/web/static/src/legacy/scss/utils.scss b/addons/web/static/src/legacy/scss/utils.scss index 721607a1140..0608ea491b0 100644 --- a/addons/web/static/src/legacy/scss/utils.scss +++ b/addons/web/static/src/legacy/scss/utils.scss @@ -449,4 +449,3 @@ } } } - diff --git a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js index 77e8ff0c6b6..78f95043204 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js +++ b/addons/web_editor/static/lib/odoo-editor/src/OdooEditor.js @@ -22,6 +22,7 @@ import { getListMode, getOuid, insertText, + isColorGradient, nodeSize, preserveCursor, setCursor, @@ -999,27 +1000,73 @@ export class OdooEditor extends EventTarget { } } + /** + * Displays the text colors (foreground ink and background highlight) + * based on the current text cursor position. For gradients, displays + * the average color of the gradient. + * + * @param {object} [params] + * @param {string} [params.foreColor] - forces the 'foreColor' in the + * toolbar instead of determining it from the cursor position + * @param {string} [params.hiliteColor] - forces the 'hiliteColor' in the + * toolbar instead of determining it from the cursor position + */ updateColorpickerLabels(params = {}) { - const foreColor = params.foreColor || rgbToHex(document.queryCommandValue('foreColor')); + function hexFromColor(color) { + if (isColorGradient(color)) { + // For gradients, compute the average color + color = color.match(/gradient(.*)/)[0]; + let r = 0, g = 0, b = 0, count = 0; + for (const entry of color.matchAll(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)/g)) { + count++; + r += parseInt(entry[1], 10); + g += parseInt(entry[2], 10); + b += parseInt(entry[3], 10); + } + color = `rgb(${Math.round(r / count)}, ${Math.round(g / count)}, ${Math.round(b / count)})`; + } + return rgbToHex(color); + } + let foreColor = params.foreColor; + let hiliteColor = params.hiliteColor; + + // Determine colors at cursor position + const sel = this.document.getSelection(); + if (sel.rangeCount && (!foreColor || !hiliteColor)) { + const endContainer = closestElement(sel.getRangeAt(0).endContainer); + const computedStyle = getComputedStyle(endContainer); + const backgroundImage = computedStyle.backgroundImage; + const hasGradient = isColorGradient(backgroundImage); + const hasTextGradientClass = endContainer.classList.contains('text-gradient'); + if (!foreColor) { + if (hasGradient && hasTextGradientClass) { + foreColor = backgroundImage; + } else { + foreColor = document.queryCommandValue('foreColor'); + } + } + if (!hiliteColor) { + if (hasGradient && !hasTextGradientClass) { + hiliteColor = backgroundImage; + } else { + hiliteColor = computedStyle.backgroundColor; + } + } + } + + // display colors in toolbar buttons + foreColor = hexFromColor(foreColor); this.toolbar.style.setProperty('--fore-color', foreColor); const foreColorInput = this.toolbar.querySelector('#foreColor input'); if (foreColorInput) { foreColorInput.value = foreColor; } - let hiliteColor = params.hiliteColor; - if (!hiliteColor) { - const sel = this.document.getSelection(); - if (sel.rangeCount) { - const endContainer = closestElement(sel.getRangeAt(0).endContainer); - const hiliteColorRgb = getComputedStyle(endContainer).backgroundColor; - hiliteColor = rgbToHex(hiliteColorRgb); - } - } + hiliteColor = hexFromColor(hiliteColor); this.toolbar.style.setProperty('--hilite-color', hiliteColor); const hiliteColorInput = this.toolbar.querySelector('#hiliteColor input'); if (hiliteColorInput) { - hiliteColorInput.value = hiliteColor.length <= 7 ? hiliteColor : rgbToHex(hiliteColor); + hiliteColorInput.value = hiliteColor.length <= 7 ? hiliteColor : hexFromColor(hiliteColor); } } diff --git a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js index 74c92273109..d2c5cb96443 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js +++ b/addons/web_editor/static/lib/odoo-editor/src/commands/commands.js @@ -18,6 +18,7 @@ import { insertText, isBlock, isBold, + isColorGradient, isContentTextNode, isShrunkBlock, isVisible, @@ -138,11 +139,25 @@ function align(editor, mode) { function colorElement(element, color, mode) { const newClassName = element.className .replace(mode === 'color' ? TEXT_CLASSES_REGEX : BG_CLASSES_REGEX, '') + .replace(/\btext-gradient\b/g, '') // cannot be combined with setting a background .replace(/\s+/, ' '); element.className !== newClassName && (element.className = newClassName); + element.style['background-image'] = ''; + if (mode === 'backgroundColor') { + element.style['background'] = ''; + } if (color.startsWith('text') || color.startsWith('bg-')) { element.style[mode] = ''; - element.className += ' ' + color; + element.classList.add(color); + } else if (isColorGradient(color)) { + element.style[mode] = ''; + if (mode === 'color') { + element.style['background'] = ''; + element.style['background-image'] = color; + element.classList.add('text-gradient'); + } else { + element.style['background-image'] = color; + } } else { element.style[mode] = color; } @@ -160,6 +175,17 @@ function hasColor(element, mode) { const style = element.style; const parent = element.parentNode; const classRegex = mode === 'color' ? TEXT_CLASSES_REGEX : BG_CLASSES_REGEX; + if (isColorGradient(style['background-image'])) { + if (element.classList.contains('text-gradient')) { + if (mode === 'color') { + return true; + } + } else { + if (mode !== 'color') { + return true; + } + } + } return ( (style[mode] && style[mode] !== 'inherit' && style[mode] !== parent.style[mode]) || (classRegex.test(element.className) && diff --git a/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js b/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js index 68ffd2f8535..cd4eb60cb7c 100644 --- a/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js +++ b/addons/web_editor/static/lib/odoo-editor/src/utils/utils.js @@ -1216,6 +1216,15 @@ export function isShrunkBlock(blockEl) { return isEmptyBlock(blockEl) && !blockEl.querySelector('br'); } +/** + * @param {string} [value] + * @returns {boolean} + */ +export function isColorGradient(value) { + // FIXME duplicated in @web_editor/utils.js + return value && value.includes('-gradient('); +} + //------------------------------------------------------------------------------ // DOM Modification //------------------------------------------------------------------------------ diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js index a473f56b147..d4a9bac607e 100644 --- a/addons/web_editor/static/src/js/common/utils.js +++ b/addons/web_editor/static/src/js/common/utils.js @@ -159,6 +159,31 @@ function _areCssValuesEqual(value1, value2, cssProp, $target) { return true; } + // They may be gradients + const value1IsGradient = _isColorGradient(value1); + const value2IsGradient = _isColorGradient(value2); + if (value1IsGradient !== value2IsGradient) { + return false; + } + if (value1IsGradient) { + // Kinda hacky and probably inneficient but probably the easiest way: + // applied the value as background-image of two fakes elements and + // compare their computed value. + const temp1El = document.createElement('div'); + temp1El.style.backgroundImage = value1; + document.body.appendChild(temp1El); + value1 = getComputedStyle(temp1El).backgroundImage; + document.body.removeChild(temp1El); + + const temp2El = document.createElement('div'); + temp2El.style.backgroundImage = value2; + document.body.appendChild(temp2El); + value2 = getComputedStyle(temp2El).backgroundImage; + document.body.removeChild(temp2El); + + return value1 === value2; + } + // Convert the second value in the unit of the first one and compare // floating values const data = _getNumericAndUnit(value1); @@ -237,7 +262,8 @@ function _normalizeColor(color) { * @returns {string|false} the src of the image or false if not parsable */ function _getBgImageURL(el) { - const string = $(el).css('background-image'); + const parts = _backgroundImageCssToParts($(el).css('background-image')); + const string = parts.url || ''; const match = string.match(/^url\((['"])(.*?)\1\)$/); if (!match) { return ''; @@ -250,6 +276,47 @@ function _getBgImageURL(el) { } return matchedURL; } +/** + * Extracts url and gradient parts from the background-image CSS property. + * + * @param {string} CSS 'background-image' property value + * @returns {Object} contains the separated 'url' and 'gradient' parts + */ +function _backgroundImageCssToParts(css) { + const parts = {}; + css = css || ''; + if (css.startsWith('url(')) { + const urlEnd = css.indexOf(')') + 1; + parts.url = css.substring(0, urlEnd).trim(); + const commaPos = css.indexOf(',', urlEnd); + css = commaPos > 0 ? css.substring(commaPos + 1) : ''; + } + if (_isColorGradient(css)) { + parts.gradient = css.trim(); + } + return parts; +} +/** + * Combines url and gradient parts into a background-image CSS property value + * + * @param {Object} contains the separated 'url' and 'gradient' parts + * @returns {string} CSS 'background-image' property value + */ +function _backgroundImagePartsToCss(parts) { + let css = parts.url || ''; + if (parts.gradient) { + css += (css ? ', ' : '') + parts.gradient; + } + return css || 'none'; +} +/** + * @param {string} [value] + * @returns {boolean} + */ +function _isColorGradient(value) { + // FIXME duplicated in odoo-editor/utils.js + return value && value.includes('-gradient('); +} return { CSS_SHORTHANDS: CSS_SHORTHANDS, @@ -261,9 +328,12 @@ return { getNumericAndUnit: _getNumericAndUnit, areCssValuesEqual: _areCssValuesEqual, isColorCombinationName: _isColorCombinationName, + isColorGradient: _isColorGradient, computeColorClasses: _computeColorClasses, getCSSVariableValue: _getCSSVariableValue, normalizeColor: _normalizeColor, getBgImageURL: _getBgImageURL, + backgroundImageCssToParts: _backgroundImageCssToParts, + backgroundImagePartsToCss: _backgroundImagePartsToCss, }; }); 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 2aeb71d8b07..677fe4aacff 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -12,6 +12,8 @@ const weUtils = require('web_editor.utils'); const { normalizeColor, getBgImageURL, + backgroundImageCssToParts, + backgroundImagePartsToCss, DEFAULT_PALETTE, } = weUtils; var weWidgets = require('wysiwyg.widgets'); @@ -480,6 +482,13 @@ const UserValueWidget = Widget.extend({ const uniqArr = arr.filter((v, i, arr) => i === arr.indexOf(v)); this._methodsParams.optionsPossibleValues[methodName] = uniqArr; } + + // Method names come from the widget's dataset whose keys' order cannot + // be relied on. We explicitely sort them by alphabetical order allowing + // consistent behavior, while relying on order for such methods should + // not be done when possible (the methods should be independent from + // each other when possible). + this._methodsNames.sort(); }, /** * @param {boolean} [previewMode=false] @@ -1329,7 +1338,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ */ open: function () { if (this.colorPalette.setSelectedColor) { - this.colorPalette.setSelectedColor(this._value); + this.colorPalette.setSelectedColor(this._ccValue, this._value); } else { // TODO review in master, this does async stuff. Maybe the open // method should now be async. This is not really robust as the @@ -1363,14 +1372,23 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ * @override */ getValue: function (methodName) { - if (typeof this._previewColor === 'string') { - return this._previewColor; - } - if (typeof this._customColorValue === 'string') { - return this._customColorValue; - } + const isCCMethod = (this._methodsParams.withCombinations === methodName); let value = this._super(...arguments); + if (isCCMethod) { + value = this._ccValue; + } else if (typeof this._customColorValue === 'string') { + value = this._customColorValue; + } + + // TODO strange there is some processing below for the normal value but + // not for the preview value? To check in older stable versions as well. + if (typeof this._previewColor === 'string') { + return isCCMethod ? this._previewCC : this._previewColor; + } + if (value) { + // TODO probably something to be done to handle gradients properly + // in this code. const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor'); const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible'); if ((useCssColor || cssCompatible) && !ColorpickerWidget.isCSSColor(value)) { @@ -1393,34 +1411,46 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ * @override */ isActive: function () { - return !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); + return !!this._ccValue + || !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); }, /** * Updates the color preview + re-render the whole color palette widget. * * @override */ - async setValue(color) { - await this._super(...arguments); + async setValue(color, methodName, ...rest) { + // The colorpicker widget can hold two values: a color combination and + // a normal color or a gradient. The base `_value` will hold the normal + // color or the gradient value. The color combination one will be + // available in `_ccValue`. + const isCCMethod = (this._methodsParams.withCombinations === methodName); + // Always call _super but don't change _value if meant for the CC value. + await this._super(isCCMethod ? this._value : color, methodName, ...rest); + if (isCCMethod) { + this._ccValue = color; + } await this._colorPaletteRenderPromise; const classes = weUtils.computeColorClasses(this.colorPalette.getColorNames()); this.colorPreviewEl.classList.remove(...classes); this.colorPreviewEl.style.removeProperty('background-color'); - + this.colorPreviewEl.style.removeProperty('background-image'); + if (this._ccValue) { + this.colorPreviewEl.classList.add('o_cc', `o_cc${this._ccValue}`); + } if (this._value) { if (ColorpickerWidget.isCSSColor(this._value)) { this.colorPreviewEl.style.backgroundColor = this._value; - } else if (weUtils.isColorCombinationName(this._value)) { - this.colorPreviewEl.classList.add('o_cc', `o_cc${this._value}`); - } else { + } else if (!weUtils.isColorGradient(this._value)) { this.colorPreviewEl.classList.add(`bg-${this._value}`); + } else { + this.colorPreviewEl.style.backgroundImage = this._value; } } }, - //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -1431,14 +1461,21 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ */ _renderColorPalette: function () { const options = { + selectedCC: this._ccValue, selectedColor: this._value, }; if (this.options.dataAttributes.excluded) { options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(','); } + if (this.options.dataAttributes.opacity) { + options.opacity = this.options.dataAttributes.opacity; + } if (this.options.dataAttributes.withCombinations) { options.withCombinations = !!this.options.dataAttributes.withCombinations; } + if (this.options.dataAttributes.withGradients) { + options.withGradients = !!this.options.dataAttributes.withGradients; + } if (this.options.dataAttributes.selectedTab) { options.selectedTab = this.options.dataAttributes.selectedTab; } @@ -1479,9 +1516,13 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ * @param {Event} ev */ _onColorPicked: function (ev) { + this._previewCC = false; this._previewColor = false; this._customColorValue = false; + + this._ccValue = ev.data.ccValue; this._value = ev.data.color; + this._onUserValueChange(ev); }, /** @@ -1491,6 +1532,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ * @param {Event} ev */ _onColorHovered: function (ev) { + this._previewCC = ev.data.ccValue; this._previewColor = ev.data.color; this._onUserValuePreview(ev); }, @@ -1501,6 +1543,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ * @param {Event} ev */ _onColorLeft: function (ev) { + this._previewCC = false; this._previewColor = false; this._onUserValueReset(ev); }, @@ -3031,31 +3074,42 @@ const SnippetOptionWidget = Widget.extend({ this.$target.trigger('background-color-event', previewMode); } - const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; + // Always reset the inline style first to not put inline style on an + // element which already have this style through css stylesheets. + let cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; for (const cssProp of cssProps) { - // Always reset the inline style first to not put inline style on an - // element which already have this style through css stylesheets. this.$target[0].style.setProperty(cssProp, ''); } if (params.extraClass) { this.$target.removeClass(params.extraClass); } + // Plain color and gradient are mutually exclusive as background so in + // case we edit a background-color we also have to reset the gradient + // part of the background-image property (the opposite is handled by the + // fact that editing a gradient as background is done by calling this + // method with background-color as property too, so it is automatically + // reset anyway). + let bgImageParts = undefined; + if (params.cssProperty === 'background-color') { + const styles = getComputedStyle(this.$target[0]); + bgImageParts = backgroundImageCssToParts(styles['background-image']); + delete bgImageParts.gradient; + const combined = backgroundImagePartsToCss(bgImageParts); + this.$target[0].style.setProperty('background-image', ''); + applyCSS.call(this, 'background-image', combined, styles); + } // Only allow to use a color name as a className if we know about the // other potential color names (to remove) and if we know about a prefix // (otherwise we suppose that we should use the actual related color). + // Note: color combinations classes are handled by a dedicated method, + // as they can be combined with normal classes. if (params.colorNames && params.colorPrefix) { - const classes = weUtils.computeColorClasses(params.colorNames, params.colorPrefix); + const colorNames = params.colorNames.filter(name => !weUtils.isColorCombinationName(name)); + const classes = weUtils.computeColorClasses(colorNames, params.colorPrefix); this.$target[0].classList.remove(...classes); - if (weUtils.isColorCombinationName(widgetValue)) { - // Those are the special color combinations classes. Just have - // to add it (and adding the potential extra class) then leave. - this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`, params.extraClass); - _restoreTransitions(); - return; - } - if (params.colorNames.includes(widgetValue)) { + if (colorNames.includes(widgetValue)) { const originalCSSValue = window.getComputedStyle(this.$target[0])[cssProps[0]]; const className = params.colorPrefix + widgetValue; this.$target[0].classList.add(className); @@ -3074,6 +3128,8 @@ const SnippetOptionWidget = Widget.extend({ } } + const styles = window.getComputedStyle(this.$target[0]); + // At this point, the widget value is either a property/color name or // an actual css property value. If it is a property/color name, we will // apply a css variable as style value. @@ -3082,6 +3138,21 @@ const SnippetOptionWidget = Widget.extend({ widgetValue = `var(--${widgetValue})`; } + // In case of background-color edition, we could receive a gradient, in + // which case the value has to be combined with the potential background + // image (real image). + if (params.cssProperty === 'background-color' && weUtils.isColorGradient(widgetValue)) { + cssProps = ['background-image']; + bgImageParts.gradient = widgetValue; + widgetValue = backgroundImagePartsToCss(bgImageParts); + + // Also force the background-color to transparent as otherwise it + // won't act as a "gradient replacing the color combination + // background" but be applied over it (which would be the opposite + // of what happens when editing the background color). + applyCSS.call(this, 'background-color', 'rgba(0, 0, 0, 0)', styles); + } + // replacing ', ' by ',' to prevent attributes with internal space separators from being split: // eg: "rgba(55, 12, 47, 1.9) 47px" should be split as ["rgba(55,12,47,1.9)", "47px"] const values = widgetValue.replace(/,\s/g, ',').split(/\s+/g); @@ -3102,7 +3173,6 @@ const SnippetOptionWidget = Widget.extend({ } } - const styles = window.getComputedStyle(this.$target[0]); let hasUserValue = false; for (let i = cssProps.length - 1; i > 0; i--) { hasUserValue = applyCSS.call(this, cssProps[i], values.pop(), styles) || hasUserValue; @@ -3123,6 +3193,22 @@ const SnippetOptionWidget = Widget.extend({ _restoreTransitions(); }, + /** + * Sets a color combination. + * + * @see this.selectClass for parameters + */ + async selectColorCombination(previewMode, widgetValue, params) { + if (params.colorNames) { + const names = params.colorNames.filter(weUtils.isColorCombinationName); + const classes = weUtils.computeColorClasses(names); + this.$target[0].classList.remove(...classes); + + if (widgetValue) { + this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`); + } + } + }, //-------------------------------------------------------------------------- // Public @@ -3398,10 +3484,15 @@ const SnippetOptionWidget = Widget.extend({ return attrValue || params.attributeDefaultValue || ''; } case 'selectStyle': { + let usedCC = undefined; if (params.colorPrefix && params.colorNames) { for (const c of params.colorNames) { const className = weUtils.computeColorClasses([c], params.colorPrefix)[0]; if (this.$target[0].classList.contains(className)) { + if (weUtils.isColorCombinationName(c)) { + usedCC = c; + continue; + } return c; } } @@ -3414,6 +3505,18 @@ const SnippetOptionWidget = Widget.extend({ const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition'); const styles = window.getComputedStyle(this.$target[0]); + + if (params.cssProperty === 'background-color') { + // Check if there is a gradient, in that case this is the + // value to be returned, we normally not allow color and + // gradient at the same time (the option would remove one + // if editing the other). + const parts = backgroundImageCssToParts(styles['background-image']); + if (parts.gradient) { + return parts.gradient; + } + } + const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; const cssValues = cssProps.map(cssProp => { let value = styles[cssProp].trim(); @@ -3438,7 +3541,44 @@ const SnippetOptionWidget = Widget.extend({ _restoreTransitions(); - return cssValues.join(' '); + const value = cssValues.join(' '); + + if (params.cssProperty === 'background-color' && params.withCombinations) { + if (usedCC) { + const ccValue = weUtils.getCSSVariableValue(`o-cc${usedCC}-bg`).trim(); + if (weUtils.areCssValuesEqual(value, ccValue)) { + // Prevent to consider that a color is used as CC + // override in case that color is the same as the + // one used in that CC. + return ''; + } + } else { + const rgba = ColorpickerWidget.convertCSSColorToRgba(value); + if (rgba && rgba.opacity < 0.001) { + // Prevent to consider a transparent color is + // applied as background unless it is to override a + // CC. Simply allows to add a CC on a transparent + // snippet in the first place. + return ''; + } + } + } + + return value; + } + case 'selectColorCombination': { + if (params.colorNames) { + for (const c of params.colorNames) { + if (!weUtils.isColorCombinationName(c)) { + continue; + } + const className = weUtils.computeColorClasses([c])[0]; + if (this.$target[0].classList.contains(className)) { + return c; + } + } + } + return ''; } } }, @@ -5038,7 +5178,10 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ * @override */ _applyImage(img) { - this.$target.css('background-image', `url('${img.getAttribute('src')}')`); + const parts = backgroundImageCssToParts(this.$target.css('background-image')); + parts.url = `url('${img.getAttribute('src')}')`; + const combined = backgroundImagePartsToCss(parts); + this.$target.css('background-image', combined); }, //-------------------------------------------------------------------------- @@ -5060,20 +5203,6 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ }); registry.BackgroundToggler = SnippetOptionWidget.extend({ - /** - * @override - */ - start() { - this.$target.on('content_changed.BackgroundToggler', this._onExternalUpdate.bind(this)); - return this._super(...arguments); - }, - /** - * @override - */ - destroy() { - this._super(...arguments); - this.$target.off('.BackgroundToggler'); - }, //-------------------------------------------------------------------------- // Options @@ -5086,6 +5215,7 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ */ toggleBgImage(previewMode, widgetValue, params) { if (!widgetValue) { + this.$target.find('> .o_we_bg_filter').remove(); // TODO: use setWidgetValue instead of calling background directly when possible const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); const bgImageOpt = bgImageWidget.getParent(); @@ -5108,23 +5238,39 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ return shapeOption._toggleShape(); }, /** - * Toggles background filter on or off. + * Sets a color filter. * * @see this.selectClass for parameters */ - toggleBgFilter(previewMode, widgetValue, params) { - if (widgetValue) { - const bgFilterEl = document.createElement('div'); - bgFilterEl.classList.add('o_we_bg_filter', 'bg-black-50'); + async selectFilterColor(previewMode, widgetValue, params) { + // Find the filter element. + let filterEl = this.$target[0].querySelector(':scope > .o_we_bg_filter'); + + // If the filter would be transparent, remove it / don't create it. + const rgba = widgetValue && ColorpickerWidget.convertCSSColorToRgba(widgetValue); + if (!widgetValue || rgba && rgba.opacity < 0.001) { + if (filterEl) { + filterEl.remove(); + } + return; + } + + // Create the filter if necessary. + if (!filterEl) { + filterEl = document.createElement('div'); + filterEl.classList.add('o_we_bg_filter'); const lastBackgroundEl = this._getLastPreFilterLayerElement(); if (lastBackgroundEl) { - $(lastBackgroundEl).after(bgFilterEl); + $(lastBackgroundEl).after(filterEl); } else { - this.$target.prepend(bgFilterEl); + this.$target.prepend(filterEl); } - } else { - this.$target.find('.o_we_bg_filter').remove(); } + + // Apply the color on the filter. + const obj = createPropertyProxy(this, '$target', $(filterEl)); + params.cssProperty = 'background-color'; + return this.selectStyle.call(obj, previewMode, widgetValue, params); }, //-------------------------------------------------------------------------- @@ -5141,14 +5287,20 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ const bgImageOpt = bgImageWidget.getParent(); return !!bgImageOpt._computeWidgetState('background', bgImageWidget.getMethodsParams('background')); } - case 'toggleBgFilter': { - return this._hasBgFilter(); - } case 'toggleBgShape': { const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); const shapeOption = shapeWidget.getParent(); return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape')); } + case 'selectFilterColor': { + const filterEl = this.$target[0].querySelector(':scope > .o_we_bg_filter'); + if (!filterEl) { + return ''; + } + const obj = createPropertyProxy(this, '$target', $(filterEl)); + params.cssProperty = 'background-color'; + return this._computeWidgetState.call(obj, 'selectStyle', params); + } } return this._super(...arguments); }, @@ -5158,32 +5310,6 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ _getLastPreFilterLayerElement() { return null; }, - /** - * @private - * @returns {Boolean} - */ - _hasBgFilter() { - return !!this.$target.find('> .o_we_bg_filter').length; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onExternalUpdate() { - if (this._hasBgFilter() - && !this._getLastPreFilterLayerElement() - && !getBgImageURL(this.$target)) { - // No 'pre-filter' background layout anymore and no more background - // image: remove the background filter option. - // TODO there probably is a better system to implement to do that - const widget = this._requestUserValueWidgets('bg_filter_toggle_opt')[0]; - widget.enable(); - } - }, }); /** @@ -5235,14 +5361,14 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ this.previousSrc = currentSrc; break; case 'reset': - this.$target.css('background-image', `url('${this.previousSrc}')`); + this._setBackground(this.previousSrc); return; } const newURL = new URL(currentSrc, window.location.origin); newURL.searchParams.set(params.colorName, normalizeColor(widgetValue)); const src = newURL.pathname + newURL.search; await loadImage(src); - this.$target.css('background-image', `url('${src}')`); + this._setBackground(src); if (!previewMode) { this.previousSrc = src; } @@ -5313,13 +5439,16 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ * @param {string} backgroundURL */ _setBackground(backgroundURL) { + const parts = backgroundImageCssToParts(this.$target.css('background-image')); if (backgroundURL) { - this.$target.css('background-image', `url('${backgroundURL}')`); + parts.url = `url('${backgroundURL}')`; this.$target.addClass('oe_img_bg o_bg_img_center'); } else { - this.$target.css('background-image', ''); + delete parts.url; this.$target.removeClass('oe_img_bg o_bg_img_center'); } + const combined = backgroundImagePartsToCss(parts); + this.$target.css('background-image', combined); }, }); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js index ee83068772e..b6989d42559 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js @@ -34,6 +34,10 @@ const ColorPaletteWidget = Widget.extend({ * @param {boolean} [options.resetButton=true] Whether to display or not the reset button. * @param {string[]} [options.excluded=[]] Sections not to display. * @param {string[]} [options.excludeSectionOf] Extra section to exclude: the one containing the named color. + * @param {boolean} [options.withCombinations=false] Enable color combinations selection. + * @param {float} [options.opacity=1] Specify a default opacity (predefined gradients & color). + * @param {string} [options.selectedTab='theme-colors'] Tab initially selected. + * @param {boolean} [options.withGradients=false] Enable gradient selection. * @param {JQuery} [options.$editable=$()] Editable content from which the custom colors are retrieved. */ init: function (parent, options) { @@ -46,9 +50,10 @@ const ColorPaletteWidget = Widget.extend({ excludeSectionOf: null, $editable: $(), withCombinations: false, + opacity: 1, selectedTab: 'theme-colors', + withGradients: false, }, options || {}); - this.selectedColor = ''; this.resetButton = this.options.resetButton; this.withCombinations = this.options.withCombinations; @@ -69,6 +74,10 @@ const ColorPaletteWidget = Widget.extend({ 'transparent_grayscale', 'common_grays', ], + }, + { + id: 'gradients', + pickers: this.options.withGradients ? ['predefined_gradients'] : [], }]; this.sections = {}; @@ -118,15 +127,18 @@ const ColorPaletteWidget = Widget.extend({ pickerEl.dataset.name = 'custom'; break; default: - pickerEl = colorpickerEl.querySelector(`[data-name="${pickerId}"]`).cloneNode(true); + pickerEl = colorpickerEl.querySelector(`[data-name="${pickerId}"]`); + pickerEl = pickerEl && pickerEl.cloneNode(true); } - sectionEl.appendChild(pickerEl); + if (pickerEl) { + sectionEl.appendChild(pickerEl); - if (!this.options.excluded.includes(pickerId)) { - sectionIsEmpty = false; + if (!this.options.excluded.includes(pickerId)) { + sectionIsEmpty = false; + } + + this.pickers[pickerId] = pickerEl; } - - this.pickers[pickerId] = pickerEl; }); // If the section is empty, hide it and @@ -141,6 +153,16 @@ const ColorPaletteWidget = Widget.extend({ this.sections[tab.id] = sectionEl; }); + // Predefined gradient opacity + if (this.options.withGradients && this.options.opacity !== 1) { + this.pickers['predefined_gradients'].querySelectorAll('button').forEach(elem => { + let gradient = elem.dataset.color; + gradient = gradient.replaceAll(/rgba?(\(\s*\d+\s*,\s*\d+\s*,\s*\d+)(?:\s*,.+?)?\)/g, + `rgba$1, ${this.options.opacity})`); + elem.dataset.color = gradient.replaceAll(/\s+/g, ''); + }); + } + // Switch to the correct tab const selectedButtonIndex = this.tabs.map(tab => tab.id).indexOf(this.options.selectedTab); this._selectTabFromButton(this.el.querySelectorAll('button')[selectedButtonIndex]); @@ -183,6 +205,9 @@ const ColorPaletteWidget = Widget.extend({ this.colorToColorNames = {}; this.el.querySelectorAll('button[data-color]').forEach(elem => { const colorName = elem.dataset.color; + if (weUtils.isColorGradient(colorName)) { + return; + } const $color = $(elem); const isCCName = weUtils.isColorCombinationName(colorName); if (isCCName) { @@ -199,6 +224,9 @@ const ColorPaletteWidget = Widget.extend({ // Select selected Color and build customColors. // If no color is selected selectedColor is an empty string (transparent is interpreted as no color) + if (this.options.selectedCC) { + this.selectedCC = this.options.selectedCC; + } if (this.options.selectedColor) { let selectedColor = this.options.selectedColor; if (compatibilityColorNames.includes(selectedColor)) { @@ -218,12 +246,14 @@ const ColorPaletteWidget = Widget.extend({ if (defaultColor && !ColorpickerWidget.isCSSColor(defaultColor)) { defaultColor = weUtils.getCSSVariableValue(defaultColor, this.style); } + if (!defaultColor && this.options.opacity !== 1) { + defaultColor = 'rgba(0, 0, 0, ' + this.options.opacity + ')'; + } this.colorPicker = new ColorpickerWidget(this, { defaultColor: defaultColor, }); await this.colorPicker.appendTo(this.sections['custom-colors']); } - return res; }, /** @@ -233,12 +263,33 @@ const ColorPaletteWidget = Widget.extend({ return this.colorNames; }, /** - * Sets the currently selected color + * Gets the currently selected colors. * + * @returns {Object} ccValue and color (plain color or gradient). + */ + getSelectedColors() { + return { + ccValue: this.selectedCC, + color: this.selectedColor, + }; + }, + /** + * Sets the currently selected colors + * + * @param {string|number} ccValue * @param {string} color rgb[a] */ - setSelectedColor: function (color) { - this._selectColor({color: color}); + setSelectedColor: function (ccValue, color) { + if (color === 'rgba(0, 0, 0, 0)' && this.options.opacity !== 1) { + color = 'rgba(0, 0, 0, ' + this.options.opacity + ')'; + } + this._selectColor({ + ccValue: ccValue, + color: color, + }); + // This is called on open, restore default tab selection + const selectedButtonIndex = this.tabs.map(tab => tab.id).indexOf(this.options.selectedTab); + this._selectTabFromButton(this.el.querySelectorAll('button')[selectedButtonIndex]); }, //-------------------------------------------------------------------------- @@ -330,18 +381,30 @@ const ColorPaletteWidget = Widget.extend({ */ _getButtonInfo: function (buttonEl) { const bgColor = buttonEl.style.backgroundColor; - return { - color: bgColor ? ColorpickerWidget.normalizeCSSColor(bgColor) : buttonEl.dataset.color || '', + const value = buttonEl.dataset.color || (bgColor && bgColor !== 'initial' ? ColorpickerWidget.normalizeCSSColor(bgColor) : '') || ''; + const info = { target: buttonEl, }; + if (!value) { + info.ccValue = ''; + info.color = ''; + } else if (weUtils.isColorCombinationName(value)) { + info.ccValue = value; + } else { + info.color = value; + } + return info; }, /** * Set the selectedColor and trigger an event * - * @param {Object} color + * @param {Object} colorInfo + * @param {string} [colorInfo.ccValue] + * @param {string} [colorInfo.color] * @param {string} [eventName] */ _selectColor: function (colorInfo, eventName) { + this.selectedCC = colorInfo.ccValue; this.selectedColor = colorInfo.color = this.colorToColorNames[colorInfo.color] || colorInfo.color; if (eventName) { this.trigger_up(eventName, colorInfo); @@ -353,15 +416,18 @@ const ColorPaletteWidget = Widget.extend({ } }, /** - * Mark the selected color + * Marks the selected colors. * * @private */ _markSelectedColor: function () { - this.el.querySelectorAll('button.selected').forEach(el => el.classList.remove('selected')); - const buttonToSelectEls = this.el.querySelectorAll(`button[data-color="${this.selectedColor}"], button[style*="background-color:${this.selectedColor};"]`); - for (const buttonToSelectEl of buttonToSelectEls) { - buttonToSelectEl.classList.add('selected'); + for (const buttonEl of this.el.querySelectorAll('button')) { + // TODO buttons should only be search by data-color value + // instead of style but seems necessary for custom colors right + // now... + const value = buttonEl.dataset.color || buttonEl.style.backgroundColor; + buttonEl.classList.toggle('selected', value + && (this.selectedCC === value || weUtils.areCssValuesEqual(this.selectedColor, value))); } }, /** @@ -370,7 +436,10 @@ const ColorPaletteWidget = Widget.extend({ * @private * @param {HTMLElement} buttonEl */ - _selectTabFromButton(buttonEl) { + _selectTabFromButton(buttonEl) { + this.el.querySelectorAll('.o_we_colorpicker_switch_pane_btn').forEach(el => { + el.classList.remove('active'); + }); buttonEl.classList.add('active'); this.el.querySelectorAll('.o_colorpicker_sections').forEach(el => { el.classList.toggle('d-none', el.dataset.colorTab !== buttonEl.dataset.target); @@ -389,7 +458,7 @@ const ColorPaletteWidget = Widget.extend({ */ _onColorButtonClick: function (ev) { const buttonEl = ev.currentTarget; - const colorInfo = this._getButtonInfo(buttonEl); + const colorInfo = Object.assign(this.getSelectedColors(), this._getButtonInfo(buttonEl)); this._selectColor(colorInfo, 'color_picked'); }, /** @@ -400,7 +469,7 @@ const ColorPaletteWidget = Widget.extend({ */ _onColorButtonEnter: function (ev) { ev.stopPropagation(); - this.trigger_up('color_hover', this._getButtonInfo(ev.currentTarget)); + this.trigger_up('color_hover', Object.assign(this.getSelectedColors(), this._getButtonInfo(ev.currentTarget))); }, /** * Called when a color button is left the data color is the color currently selected. @@ -410,10 +479,9 @@ const ColorPaletteWidget = Widget.extend({ */ _onColorButtonLeave: function (ev) { ev.stopPropagation(); - this.trigger_up('color_leave', { - color: this.selectedColor, + this.trigger_up('color_leave', Object.assign(this.getSelectedColors(), { target: ev.target, - }); + })); }, /** * Called when an update is made on the colorpicker. @@ -422,10 +490,10 @@ const ColorPaletteWidget = Widget.extend({ * @param {Event} ev */ _onColorPickerPreview: function (ev) { - this.trigger_up('color_hover', { + this.trigger_up('color_hover', Object.assign(this.getSelectedColors(), { color: ev.data.cssColor, target: this.colorPicker.el, - }); + })); }, /** * Called when a color is selected on the colorpicker (mouseup). @@ -434,10 +502,10 @@ const ColorPaletteWidget = Widget.extend({ * @param {Event} ev */ _onColorPickerSelect: function (ev) { - this._selectColor({ + this._selectColor(Object.assign(this.getSelectedColors(), { color: ev.data.cssColor, target: this.colorPicker.el, - }, 'custom_color_picked'); + }), 'custom_color_picked'); }, /** * @private @@ -445,9 +513,6 @@ const ColorPaletteWidget = Widget.extend({ */ _onSwitchPaneButtonClick(ev) { ev.stopPropagation(); - this.el.querySelectorAll('.o_we_colorpicker_switch_pane_btn').forEach(el => { - el.classList.remove('active'); - }); this._selectTabFromButton(ev.currentTarget); }, }); diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 7d8f727aac1..394203f0bbc 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -14,6 +14,7 @@ const snippetsEditor = require('web_editor.snippet.editor'); const Toolbar = require('web_editor.toolbar'); const weWidgets = require('wysiwyg.widgets'); const wysiwygUtils = require('@web_editor/js/wysiwyg/wysiwyg_utils'); +const weUtils = require('web_editor.utils'); var _t = core._t; @@ -393,7 +394,10 @@ const Wysiwyg = Widget.extend({ }, }); if (isBackground) { - $(el).css('background-image', `url('${newAttachmentSrc}')`); + const parts = weUtils.backgroundImageCssToParts($(el).css('background-image')); + parts.url = `url('${newAttachmentSrc}')`; + const combined = weUtils.backgroundImagePartsToCss(parts); + $(el).css('background-image', combined); delete el.dataset.bgSrc; } else { el.setAttribute('src', newAttachmentSrc); @@ -832,10 +836,19 @@ const Wysiwyg = Widget.extend({ const targetElement = targetNode && targetNode.nodeType === Node.ELEMENT_NODE ? targetNode : targetNode && targetNode.parentNode; + const backgroundImage = $(targetElement).css('background-image'); + let backgroundGradient = false; + if (weUtils.isColorGradient(backgroundImage)) { + const textGradient = targetElement.classList.contains('text-gradient'); + if (eventName === "foreColor" && textGradient || eventName !== "foreColor" && !textGradient) { + backgroundGradient = backgroundImage; + } + } colorpicker = new ColorPaletteWidget(this, { excluded: ['transparent_grayscale'], $editable: $(this.odooEditor.editable), // Our parent is the root widget, we can't retrieve the editable section from it... - selectedColor: $(targetElement).css(eventName === "foreColor" ? 'color' : 'backgroundColor'), + selectedColor: backgroundGradient || $(targetElement).css(eventName === "foreColor" ? 'color' : 'backgroundColor'), + withGradients: true, }); colorpicker.on('custom_color_picked color_picked', null, ev => { this._processAndApplyColor(eventName, ev.data.color); @@ -869,7 +882,7 @@ const Wysiwyg = Widget.extend({ _processAndApplyColor: function (eventName, color) { if (!color) { color = 'inherit'; - } else if (!ColorpickerWidget.isCSSColor(color)) { + } else if (!ColorpickerWidget.isCSSColor(color) && !weUtils.isColorGradient(color)) { color = (eventName === "foreColor" ? 'text-' : 'bg-') + color; } this.odooEditor.execCommand('applyColor', color, eventName === 'foreColor' ? 'color' : 'backgroundColor', this.lastMediaClicked); @@ -881,6 +894,9 @@ const Wysiwyg = Widget.extend({ _colorToHex: function (color) { if (color.startsWith('#')) { return color; + } else if (weUtils.isColorGradient(color)) { + // return gradient the way it is: updateColorpickerLabels will handle it + return color; } else { let rgbColor; if (color.startsWith('rgb')) { diff --git a/addons/web_editor/static/src/scss/secondary_variables.scss b/addons/web_editor/static/src/scss/secondary_variables.scss index 5721398fbaa..33d5019ec82 100644 --- a/addons/web_editor/static/src/scss/secondary_variables.scss +++ b/addons/web_editor/static/src/scss/secondary_variables.scss @@ -21,6 +21,7 @@ $o-color-palette: map-get($o-color-palettes, $o-color-palette-name) or (); // Original color palette can contain override of the default combinations (so keep 'null' values for this merge) $o-color-palette: map-merge($-combination-additions, $o-color-palette); $o-color-palette: map-merge($o-base-color-palette, o-map-omit($o-color-palette)); +$o-color-palette: o-map-force-nulls($o-color-palette); // Theme colors $o-theme-color-palette: map-get($o-theme-color-palettes, $o-theme-color-palette-name) or () !default; @@ -42,6 +43,7 @@ $o-theme-color-palette: map-merge(( 'dark': darken(desaturate($-main-color, 80%), min(70%, max(0%, $-main-color-lightness - 10%))), // Does not lower under 10% lightness ), o-map-omit($o-theme-color-palette)); $o-theme-color-palette: map-merge($o-base-theme-color-palette, o-map-omit($o-theme-color-palette)); +$o-theme-color-palette: o-map-force-nulls($o-theme-color-palette); // Gray colors // Extend grays with transparent ones (for some reason, BS4 create black-50 and @@ -49,6 +51,7 @@ $o-theme-color-palette: map-merge($o-base-theme-color-palette, o-map-omit($o-the $o-gray-color-palette: map-get($o-gray-color-palettes, $o-gray-color-palette-name) or () !default; $o-gray-color-palette: map-merge($o-transparent-grays, o-map-omit($o-gray-color-palette)); $o-gray-color-palette: map-merge($o-base-gray-color-palette, o-map-omit($o-gray-color-palette)); +$o-gray-color-palette: o-map-force-nulls($o-gray-color-palette); $o-color-system-initialized: false; 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 f973d776b56..f89ced84c4d 100644 --- a/addons/web_editor/static/src/scss/web_editor.common.scss +++ b/addons/web_editor/static/src/scss/web_editor.common.scss @@ -787,3 +787,11 @@ section, .oe_img_bg, [data-oe-shape-data] { .o_we_bg_filter { @extend %o-we-background-layer; } + +// Gradient +// TODO should be in the editor lib since it is handled there... but could not +// find the right place for it. +.text-gradient { + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} diff --git a/addons/web_editor/static/src/scss/web_editor.variables.scss b/addons/web_editor/static/src/scss/web_editor.variables.scss index 2add0579ddd..91f12e6b63c 100644 --- a/addons/web_editor/static/src/scss/web_editor.variables.scss +++ b/addons/web_editor/static/src/scss/web_editor.variables.scss @@ -446,6 +446,21 @@ $o-we-sidebar-content-field-toggle-control-shadow: 0 2px 3px 0 $o-we-bg-darkest // Edited content //------------------------------------------------------------------------------ +// Useful to force null values (in website, null values set by the user +// are removed to act as "removing the user custo and resetting to theme +// default": ideally, we should review that system to actually removing the +// custos in that case instead of setting a null value but this cannot be +// migrated at the moment). +@function o-map-force-nulls($map) { + $-map: (); + @each $key, $value in $map { + $-map: map-merge($-map, ( + $key: if($value == 'NULL', null, $value), + )); + } + @return $-map; +} + $o-support-13-0-color-system: false !default; $o-checklist-margin-left: 20px; @@ -482,10 +497,6 @@ $o-color-palettes: ( 'o-color-3': #F8F9FA, 'o-color-4': #FFFFFF, 'o-color-5': #343a40, - - 'menu': 2, - 'footer': 2, - 'copyright': 5, ), ) !default; $o-color-palette-name: 'base-1' !default; diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss index fc9ac21e649..5bfbf05f60d 100644 --- a/addons/web_editor/static/src/scss/wysiwyg.scss +++ b/addons/web_editor/static/src/scss/wysiwyg.scss @@ -117,8 +117,15 @@ $o-we-zindex: $o-we-overlay-zindex + 1 !default; } // Specific elements - #colorInputButtonGroup label:last-of-type .btn { - margin: 0 1px 0 -1px; + #colorInputButtonGroup { + label:last-of-type .btn { + margin: 0 1px 0 -1px; + } + + .note-back-color-preview.dropup .dropdown-menu { + // Highlight palette alignment + left: -52px; + } } .oe-tablepicker { margin: 1px 2px -8px 2px !important; diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml index b8ca8082c24..36045537b79 100644 --- a/addons/web_editor/static/src/xml/snippets.xml +++ b/addons/web_editor/static/src/xml/snippets.xml @@ -51,12 +51,17 @@ Theme -
tata
', + 'toto toto toto
tata
', "should have rendered the field correctly in edit"); form.destroy(); diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml index 76818440440..cfc93e79176 100644 --- a/addons/web_editor/views/editor.xml +++ b/addons/web_editor/views/editor.xml @@ -60,6 +60,21 @@