diff --git a/addons/web/static/src/legacy/js/widgets/colorpicker.js b/addons/web/static/src/legacy/js/widgets/colorpicker.js index a2854d85131..5e80566bdbc 100644 --- a/addons/web/static/src/legacy/js/widgets/colorpicker.js +++ b/addons/web/static/src/legacy/js/widgets/colorpicker.js @@ -26,6 +26,7 @@ var ColorpickerWidget = Widget.extend({ * @param {Object} [options] * @param {string} [options.defaultColor='#FF0000'] * @param {string} [options.noTransparency=false] + * @param {boolean} [options.stopClickPropagation=false] */ init: function (parent, options) { this._super(...arguments); @@ -111,7 +112,11 @@ var ColorpickerWidget = Widget.extend({ setSelectedColor: function (color) { var rgba = ColorpickerWidget.convertCSSColorToRgba(color); if (rgba) { + const oldPreviewActive = this.previewActive; + this.previewActive = false; this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); + this.previewActive = oldPreviewActive; + this._updateUI(); } }, @@ -299,6 +304,9 @@ var ColorpickerWidget = Widget.extend({ * @param {Event} ev */ _onClick: function (ev) { + if (this.options.stopClickPropagation) { + ev.stopPropagation(); + } ev.originalEvent.__isColorpickerClick = true; $(ev.target).find('> .o_opacity_pointer, > .o_slider_pointer, > .o_picker_pointer').addBack('.o_opacity_pointer, .o_slider_pointer, .o_picker_pointer').focus(); if (ev.target.dataset.colorMethod === 'hex' && !this.selectedHexValue) { 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 8e02efdf2f4..32db8dccec6 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -1453,6 +1453,11 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ this.colorPreviewEl.style.backgroundImage = this._value; } } + // If the palette was already opened (e.g. modifying a gradient), the new DOM state must be + // reflected in the palette, but the tab selection must not be impacted. + if (this.colorPalette.setSelectedColor) { + this.colorPalette.setSelectedColor(this._ccValue, this._value, false); + } }, //-------------------------------------------------------------------------- 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 b6989d42559..dbe54e9132f 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 @@ -21,6 +21,16 @@ const ColorPaletteWidget = Widget.extend({ 'mouseenter .o_we_color_btn': '_onColorButtonEnter', 'mouseleave .o_we_color_btn': '_onColorButtonLeave', 'click .o_we_colorpicker_switch_pane_btn': '_onSwitchPaneButtonClick', + 'click .o_custom_gradient_editor .o_custom_gradient_btn': '_onGradientCustomButtonClick', + 'click .o_custom_gradient_editor': '_onPanelClick', + 'change .o_custom_gradient_editor input[type="text"]': '_onGradientInputChange', + 'keypress .o_custom_gradient_editor input[type="text"]': '_onGradientInputKeyPress', + 'click .o_custom_gradient_editor we-button:not(.o_remove_color)': '_onGradientButtonClick', + 'mouseenter .o_custom_gradient_editor we-button:not(.o_remove_color)': '_onGradientButtonEnter', + 'mouseleave .o_custom_gradient_editor we-button:not(.o_remove_color)': '_onGradientButtonLeave', + 'click .o_custom_gradient_scale': '_onGradientPreviewClick', + // Note: _onGradientSliderClick on slider is attached at slider creation. + 'click .o_custom_gradient_editor .o_remove_color': '_onGradientDeleteClick', }, custom_events: { 'colorpicker_select': '_onColorPickerSelect', @@ -77,7 +87,10 @@ const ColorPaletteWidget = Widget.extend({ }, { id: 'gradients', - pickers: this.options.withGradients ? ['predefined_gradients'] : [], + pickers: this.options.withGradients ? [ + 'predefined_gradients', + 'custom_gradient', + ] : [], }]; this.sections = {}; @@ -163,6 +176,36 @@ const ColorPaletteWidget = Widget.extend({ }); } + // Palette for gradient + if (this.pickers['custom_gradient']) { + this.gradientColorPicker = new ColorpickerWidget(this, { + stopClickPropagation: true, + }); + await this.gradientColorPicker.appendTo(this.sections['gradients']); + const editor = this.pickers['custom_gradient']; + this.gradientEditorParts = { + 'customButton': editor.querySelector('.o_custom_gradient_btn'), + 'customContent': editor.querySelector('.o_color_picker_inputs'), + 'linearButton': editor.querySelector('we-button[data-gradient-type="linear-gradient"]'), + 'angleRow': editor.querySelector('.o_angle_row'), + 'angle': editor.querySelector('input[data-name="angle"]'), + 'radialButton': editor.querySelector('we-button[data-gradient-type="radial-gradient"]'), + 'positionRow': editor.querySelector('.o_position_row'), + 'positionX': editor.querySelector('input[data-name="positionX"]'), + 'positionY': editor.querySelector('input[data-name="positionY"]'), + 'sizeRow': editor.querySelector('.o_size_row'), + 'scale': editor.querySelector('.o_custom_gradient_scale div'), + 'sliders': editor.querySelector('.o_slider_multi'), + 'deleteButton': editor.querySelector('.o_remove_color'), + }; + const gradient = weUtils.isColorGradient(this.options.selectedColor) && this.options.selectedColor; + this._selectGradient(gradient); + const resizeObserver = new window.ResizeObserver(() => { + this._adjustActiveSliderDelete(); + }); + resizeObserver.observe(this.gradientEditorParts.sliders); + } + // Switch to the correct tab const selectedButtonIndex = this.tabs.map(tab => tab.id).indexOf(this.options.selectedTab); this._selectTabFromButton(this.el.querySelectorAll('button')[selectedButtonIndex]); @@ -274,12 +317,16 @@ const ColorPaletteWidget = Widget.extend({ }; }, /** - * Sets the currently selected colors + * Sets the currently selected colors. + * + * Note: the tab selection is done here because of an optimization to avoid creating the whole + * palette hundreds of times when opening the THEME tab. * * @param {string|number} ccValue * @param {string} color rgb[a] + * @param {boolean} [selectTab=true] */ - setSelectedColor: function (ccValue, color) { + setSelectedColor: function (ccValue, color, selectTab = true) { if (color === 'rgba(0, 0, 0, 0)' && this.options.opacity !== 1) { color = 'rgba(0, 0, 0, ' + this.options.opacity + ')'; } @@ -287,9 +334,11 @@ const ColorPaletteWidget = Widget.extend({ 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]); + if (selectTab) { + // 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]); + } }, //-------------------------------------------------------------------------- @@ -410,10 +459,254 @@ const ColorPaletteWidget = Widget.extend({ this.trigger_up(eventName, colorInfo); } this._buildCustomColors(); - this._markSelectedColor(); if (this.colorPicker) { this.colorPicker.setSelectedColor(colorInfo.color); } + if (this.gradientColorPicker) { + const customGradient = weUtils.isColorGradient(colorInfo.color) ? colorInfo.color : false; + this._selectGradient(customGradient); + } + this._markSelectedColor(); + }, + /** + * Populates the gradient editor. + * + * @private + * @param {string} gradient CSS string + */ + _selectGradient: function (gradient) { + const editor = this.gradientEditorParts; + if (this.gradientColorPicker.$el) { + this.gradientColorPicker.do_hide(); + } + const colorSplits = []; + if (gradient) { + gradient = gradient.toLowerCase(); + // Extract colors and their positions: colors can either be in the #rrggbb format or in the + // rgb/rgba(...) format, positions are expected to be expressed as percentages + // (lengths are not supported). + for (const entry of gradient.matchAll(/(#[0-9a-f]{6}|rgba?\(\s*[0-9]+\s*,\s*[0-9]+\s*,\s*[0-9]+\s*[,\s*[0-9.]*]?\s*\))\s*([[0-9]+%]?)/g)) { + colorSplits.push([entry[1], entry[2].replace('%', '')]); + } + } + // Consider unsupported gradients as not gradients. + if (!gradient || colorSplits.length < 2) { + $(editor.customContent).addClass('d-none'); + editor.customButton.style['background-image'] = ''; + editor.customButton.dataset.color = false; + return; + } + $(editor.customContent).removeClass('d-none'); + editor.customButton.style['background-image'] = gradient; + editor.customButton.dataset.color = gradient; + // The scale display shows the gradient colors horizontally by canceling the type and angle + // which are before the first comma. + const scaleGradient = gradient.replace(/[^,]+,/, 'linear-gradient(90deg,'); + editor.scale.style['background-image'] = scaleGradient; + + const isLinear = gradient.startsWith('linear-gradient('); + // Keep track of last selected slider's position. + let lastSliderPosition; + const activeSlider = editor.sliders.querySelector('input.active'); + if (activeSlider) { + lastSliderPosition = activeSlider.value; + } + let $lastSlider; + // Rebuild sliders for each color milestone of the gradient. + editor.sliders.replaceChildren(); + for (const index in colorSplits) { + const colorSplit = colorSplits[index]; + let color = colorSplit[0]; + const position = colorSplit[1] || 100 * index / colorSplits.length; + const $slider = this._createGradientSlider(position, color); + if (position === lastSliderPosition) { + $lastSlider = $slider; + } + } + + editor.deleteButton.classList.add('d-none'); + // Update form elements related to type. + if (isLinear) { + editor.linearButton.classList.add('active'); + editor.radialButton.classList.remove('active'); + + let angle = gradient.match(/([0-9]+)deg/); + angle = angle ? angle[1] : 0; + editor.angle.value = angle; + } else { + editor.linearButton.classList.remove('active'); + editor.radialButton.classList.add('active'); + + const sizeMatch = gradient.match(/(closest|farthest)-(side|corner)/); + const size = sizeMatch ? sizeMatch[0] : 'farthest-corner'; + const $buttons = $(editor.sizeRow).find('we-button'); + $buttons.removeClass('active'); + $(editor.sizeRow).find("we-button[data-gradient-size='" + size + "']").addClass('active'); + + const position = gradient.match(/ at ([0-9]+)% ([0-9]+)%/) || ['', '50', '50']; + editor.positionX.value = position[1]; + editor.positionY.value = position[2]; + } + this._updateGradientVisibility(isLinear); + this._activateGradientSlider($lastSlider || $(this.pickers['custom_gradient'].querySelector('.o_slider_multi input'))); + }, + /** + * Adjusts the visibility of the gradient editor elements. + * + * @private + * @param {boolean} isLinear + */ + _updateGradientVisibility: function (isLinear) { + const editor = this.gradientEditorParts; + if (isLinear) { + editor.angleRow.classList.remove('d-none'); + editor.angleRow.classList.add('d-flex'); + editor.positionRow.classList.add('d-none'); + editor.positionRow.classList.remove('d-flex'); + editor.sizeRow.classList.add('d-none'); + editor.sizeRow.classList.remove('d-flex'); + } else { + editor.angleRow.classList.add('d-none'); + editor.angleRow.classList.remove('d-flex'); + editor.positionRow.classList.remove('d-none'); + editor.positionRow.classList.add('d-flex'); + editor.sizeRow.classList.remove('d-none'); + editor.sizeRow.classList.add('d-flex'); + } + }, + /** + * Removes the transparency from an rgba color. + * + * @private + * @param {string} color rgba CSS color string + * @returns {string} rgb CSS color string + */ + _opacifyColor: function (color) { + if (color.startsWith('rgba')) { + return color.replace('rgba', 'rgb').replace(/,\s*[0-9.]+\s*\)/, ')'); + } + return color; + }, + /** + * Creates and adds a slider for the gradient color definition. + * + * @private + * @param {int} position between 0 and 100 + * @param {string} color + * @returns {jQuery} created slider + */ + _createGradientSlider: function (position, color) { + const $slider = $(''); + $slider.attr('value', position); + $slider.attr('data-color', color); + $slider.css('color', this._opacifyColor(color)); + $slider.on('click', this._onGradientSliderClick.bind(this)); + $slider.appendTo(this.gradientEditorParts.sliders); + this._sortGradientSliders(); + return $slider; + }, + /** + * Activates a slider of the gradient color definition. + * + * @private + * @param {jQuery} $slider + */ + _activateGradientSlider: function ($slider) { + const $sliders = $(this.gradientEditorParts.sliders).find('input'); + $sliders.removeClass('active'); + $slider.addClass('active'); + + const color = $slider.data('color'); + // Note: show before marking the selected color as, unfortunately, + // setting the color and updating the UI accordinly relies on the widget + // already being rendered in the DOM. + this.gradientColorPicker.do_show(); + this.gradientColorPicker.setSelectedColor(color); + this._sortGradientSliders(); + this._adjustActiveSliderDelete(); + }, + /** + * Adjusts the position of the slider delete button. + * + * @private + */ + _adjustActiveSliderDelete: function () { + const $sliders = $(this.gradientEditorParts.sliders).find('input'); + const $activeSlider = $(this.gradientEditorParts.sliders).find('input.active'); + if ($sliders.length > 2 && $activeSlider.length) { + this.gradientEditorParts.deleteButton.classList.remove('d-none'); + const sliderWidth = $activeSlider.width(); + const thumbWidth = 12; // TODO find a way to access it in CSS + const deleteWidth = $(this.gradientEditorParts.deleteButton).width(); + const pixelOffset = (sliderWidth - thumbWidth) * $activeSlider[0].value / 100 + (thumbWidth - deleteWidth) / 2; + this.gradientEditorParts.deleteButton.style['margin-left'] = `${pixelOffset}px`; + this.gradientEditorParts.deleteButton.style['margin-right'] = `-${deleteWidth / 2}px`; + } else { + this.gradientEditorParts.deleteButton.classList.add('d-none'); + } + }, + /** + * Reorders the sliders of the gradient color definition by their position. + * + * @private + */ + _sortGradientSliders: function () { + const $sliderInputs = $(this.gradientEditorParts.sliders).find('input'); + for (const slider of $sliderInputs.sort((a, b) => parseInt(a.value, 10) - parseInt(b.value, 10))) { + this.gradientEditorParts.sliders.appendChild(slider); + } + }, + /** + * Computes the customized gradient from the custom gradient editor. + * + * @private + * @returns {string} gradient string corresponding to the currently selected options. + */ + _computeGradient: function () { + const editor = this.gradientEditorParts; + + const $picker = $(this.pickers['custom_gradient']); + + const colors = []; + for (const slider of $(editor.sliders).find('input')) { + const color = ColorpickerWidget.convertCSSColorToRgba($(slider).data('color')); + const colorText = color.opacity !== 100 ? `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.opacity / 100})` + : `rgb(${color.red}, ${color.green}, ${color.blue})`; + const position = slider.value; + colors.push(`${colorText} ${position}%`); + } + + const type = $picker.find('.o_type_row we-button.active').data('gradientType'); + const isLinear = type === 'linear-gradient'; + let typeParam; + if (isLinear) { + const angle = editor.angle.value || 0; + typeParam = `${angle}deg`; + } else { + const positionX = editor.positionX.value || 50; + const positionY = editor.positionY.value || 50; + const size = $picker.find('.o_size_row we-button.active').data('gradientSize'); + typeParam = `circle ${size} at ${positionX}% ${positionY}%`; + } + + return `${type}(${typeParam}, ${colors.join(', ')})`; + }, + /** + * Computes the customized gradient from the custom gradient editor and displays it. + * + * @private + * @param {boolean} isPreview + */ + _updateGradient: function (isPreview) { + const gradient = this._computeGradient(); + // Avoid updating an unchanged gradient. + if (weUtils.areCssValuesEqual(gradient, this.selectedColor) && !isPreview) { + return; + } + this.trigger_up(isPreview ? 'color_hover' : 'color_picked', Object.assign(this.getSelectedColors(), { + color: gradient, + target: this.colorPicker.el, + })); }, /** * Marks the selected colors. @@ -445,6 +738,26 @@ const ColorPaletteWidget = Widget.extend({ el.classList.toggle('d-none', el.dataset.colorTab !== buttonEl.dataset.target); }); }, + /** + * Updates a gradient color from a selection in the color picker. + * + * @private + * @param {Event} ev from gradient's colorpicker + * @param {boolean} isPreview + */ + _updateGradientColor(ev, isPreview) { + ev.stopPropagation(); + const $slider = $(this.gradientEditorParts.sliders).find('input.active'); + const color = ev.data.cssColor; + if (!weUtils.areCssValuesEqual(color, $slider.data('color'))) { + const previousColor = $slider.data('color'); + $slider.data('color', color); + this._updateGradient(isPreview); + if (isPreview) { + $slider.data('color', previousColor); + } + } + }, //-------------------------------------------------------------------------- // Handlers @@ -490,10 +803,14 @@ const ColorPaletteWidget = Widget.extend({ * @param {Event} ev */ _onColorPickerPreview: function (ev) { - this.trigger_up('color_hover', Object.assign(this.getSelectedColors(), { - color: ev.data.cssColor, - target: this.colorPicker.el, - })); + if (ev.target === this.gradientColorPicker) { + this._updateGradientColor(ev, true); + } else { + 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). @@ -502,10 +819,14 @@ const ColorPaletteWidget = Widget.extend({ * @param {Event} ev */ _onColorPickerSelect: function (ev) { - this._selectColor(Object.assign(this.getSelectedColors(), { - color: ev.data.cssColor, - target: this.colorPicker.el, - }), 'custom_color_picked'); + if (ev.target === this.gradientColorPicker) { + this._updateGradientColor(ev); + } else { + this._selectColor(Object.assign(this.getSelectedColors(), { + color: ev.data.cssColor, + target: this.colorPicker.el, + }), 'custom_color_picked'); + } }, /** * @private @@ -515,6 +836,149 @@ const ColorPaletteWidget = Widget.extend({ ev.stopPropagation(); this._selectTabFromButton(ev.currentTarget); }, + /** + * @private + * @param {Event} ev + */ + _onGradientSliderClick(ev) { + ev.stopPropagation(); + this._activateGradientSlider($(ev.target)); + this._updateGradient(); + }, + /** + * Adds a color inside the gradient based on the position clicked within the preview. + * + * @private + * @param {Event} ev + */ + _onGradientPreviewClick(ev) { + ev.stopPropagation(); + const offset = ev.offsetX; + const width = parseInt(window.getComputedStyle(ev.target).width, 10); + const position = 100 * offset / width; + + let previousColor; + let nextColor; + let previousPosition; + let nextPosition; + for (const slider of $(this.gradientEditorParts.sliders).find('input')) { + if (slider.value < position) { + previousColor = slider.dataset.color; + previousPosition = slider.value; + } else { + nextColor = slider.dataset.color; + nextPosition = slider.value; + break; + } + } + let color; + if (previousColor && nextColor) { + previousColor = ColorpickerWidget.convertCSSColorToRgba(previousColor); + nextColor = ColorpickerWidget.convertCSSColorToRgba(nextColor); + const previousRatio = (nextPosition - position) / (nextPosition - previousPosition); + const nextRatio = 1 - previousRatio; + const red = Math.round(previousRatio * previousColor.red + nextRatio * nextColor.red); + const green = Math.round(previousRatio * previousColor.green + nextRatio * nextColor.green); + const blue = Math.round(previousRatio * previousColor.blue + nextRatio * nextColor.blue); + const opacity = Math.round(previousRatio * previousColor.opacity + nextRatio * nextColor.opacity); + color = `rgba(${red}, ${green}, ${blue}, ${opacity / 100})`; + } else { + color = nextColor || previousColor || 'rgba(128, 128, 128, 0.5)'; + } + + const $slider = this._createGradientSlider(position, color); + this._activateGradientSlider($slider); + this._updateGradient(); + }, + /** + * @private + * @param {Event} ev + */ + _onPanelClick(ev) { + // Ignore to avoid closing popup. + ev.stopPropagation(); + }, + /** + * @private + * @param {Event} ev + */ + _onGradientInputChange(ev) { + this._updateGradient(); + }, + /** + * @private + * @param {Event} ev + */ + _onGradientInputKeyPress: function (ev) { + if (ev.charCode === $.ui.keyCode.ENTER) { + ev.preventDefault(); + this._onGradientInputChange(); + } + }, + /** + * @private + * @param {Event} ev + */ + _onGradientButtonClick(ev) { + const $buttons = $(ev.target).closest('span').find('we-button'); + $buttons.removeClass('active'); + $(ev.target).closest('we-button').addClass('active'); + this._updateGradient(); + }, + /** + * @private + * @param {Event} ev + */ + _onGradientButtonEnter(ev) { + ev.stopPropagation(); + const $activeButton = $(ev.target).closest('span').find('we-button.active'); + const $buttons = $(ev.target).closest('span').find('we-button'); + $buttons.removeClass('active'); + $(ev.target).closest('we-button').addClass('active'); + this._updateGradient(true); + $buttons.removeClass('active'); + $activeButton.addClass('active'); + }, + /** + * @private + * @param {Event} ev + */ + _onGradientButtonLeave(ev) { + ev.stopPropagation(); + this.trigger_up('color_leave', Object.assign(this.getSelectedColors(), { + target: ev.target, + })); + }, + /** + * @private + * @param {Event} ev + */ + _onGradientCustomButtonClick(ev) { + let gradient = this.gradientEditorParts.customButton.style['backgroundImage']; + if (!gradient) { + // default to first predefined + gradient = this.pickers['predefined_gradients'].querySelector('button').dataset.color; + } + this._selectColor(Object.assign(this.getSelectedColors(), { + color: gradient, + target: this.gradientEditorParts.customButton, + }), 'custom_color_picked'); + this._updateGradient(); + }, + /** + * @private + * @param {Event} ev + */ + _onGradientDeleteClick(ev) { + ev.stopPropagation(); + const $activeSlider = $(this.pickers['custom_gradient'].querySelector('.o_slider_multi input.active')); + $activeSlider.off(); + $activeSlider.remove(); + this.gradientEditorParts.deleteButton.classList.add('d-none'); + this.gradientEditorParts.deleteButton.classList.remove('active'); + this._updateGradient(); + this._activateGradientSlider($(this.pickers['custom_gradient'].querySelector('.o_slider_multi input'))); + }, }); //------------------------------------------------------------------------------ diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js index be14f37e3d8..539899d56e0 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_tools.js @@ -33,6 +33,8 @@ const LinkTools = Link.extend({ } const link = node || this.getOrCreateLink(editable); this._super(parent, options, editable, data, $button, link); + // Keep track of each colorpicker. + this.colorpickers = {}; }, /** * @override @@ -142,7 +144,8 @@ const LinkTools = Link.extend({ * @override */ _getLinkCustomFill: function () { - return this.$('we-selection-items[name="link-custom-color-fill"] .o_we_color_preview').css('background-color') || ''; + const $colorPreview = this.$('we-selection-items[name="link-custom-color-fill"] .o_we_color_preview'); + return $colorPreview.css('background-image') || $colorPreview.css('background-color') || ''; }, /** * @override @@ -175,10 +178,19 @@ const LinkTools = Link.extend({ if (el.dataset.value === 'custom') { const textColor = this.$link[0].style['color']; this.$('.link-custom-color-text .o_we_color_preview').css('background-color', textColor); + if (this.colorpickers['color'] && textColor && textColor !== 'false') { + // Keep currently selected tab when setting color. + this.colorpickers['color'].setSelectedColor(null, textColor, false); + } const backgroundColor = this.$link[0].style['background-color']; this.$('.link-custom-color-fill .o_we_color_preview').css('background-color', backgroundColor); const backgroundImage = this.$link[0].style['background-image']; this.$('.link-custom-color-fill .o_we_color_preview').css('background-image', backgroundImage); + const backgroundFill = (backgroundImage !== 'false' && backgroundImage) || backgroundColor; + if (this.colorpickers['background-color'] && backgroundFill && backgroundFill !== 'false') { + // Keep currently selected tab when setting color. + this.colorpickers['background-color'].setSelectedColor(null, backgroundFill, false); + } const borderWidth = this.$link[0].style['border-width']; const numberAndUnit = getNumericAndUnit(borderWidth); this.$('.link-custom-color-border input').val(numberAndUnit ? numberAndUnit[0] : "1"); @@ -194,6 +206,10 @@ const LinkTools = Link.extend({ $activeBorderStyleButton.find('div').clone().appendTo($activeBorderStyleToggler); const borderColor = this.$link[0].style['border-color']; this.$('.link-custom-color-border .o_we_color_preview').css('background-color', borderColor); + if (this.colorpickers['border-color'] && borderColor && borderColor !== 'false') { + // Keep currently selected tab when setting color. + this.colorpickers['border-color'].setSelectedColor(null, borderColor, false); + } } } }, @@ -239,6 +255,7 @@ const LinkTools = Link.extend({ selectedColor: color, withGradients: cssProperty === 'background-color', }); + this.colorpickers[cssProperty] = colorpicker; colorpicker.appendTo($(ev.target).closest('.dropdown').find('.dropdown-menu')); colorpicker.on('custom_color_picked color_picked color_hover color_leave', this, (ev) => { let color = ev.data.color; @@ -256,6 +273,7 @@ const LinkTools = Link.extend({ $colorPreview.css('background-color', color); $colorPreview.css('background-image', gradientColor); this.options.wysiwyg.odooEditor.historyStep(); + this._updateOptionsUI(); } }); }, diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 67313267d28..7bd52a505cc 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -49,6 +49,8 @@ const Wysiwyg = Widget.extend({ ? this.options.autohideToolbar : !options.snippets; this.saving_mutex = new concurrency.Mutex(); + // Keeps track of color palettes per event name. + this.colorpickers = {}; this._onDocumentMousedown = this._onDocumentMousedown.bind(this); this._onBlur = this._onBlur.bind(this); }, @@ -1057,6 +1059,29 @@ const Wysiwyg = Widget.extend({ } }, 0); }, + /** + * @private + * @param {jQuery} $ + * @param {String} eventName 'foreColor' or 'backColor' + * @returns {String} color + */ + _getSelectedColor($, eventName) { + const selection = this.odooEditor.document.getSelection(); + const range = selection.rangeCount && selection.getRangeAt(0); + const targetNode = range && range.startContainer; + 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; + } + } + return backgroundGradient || $(targetElement).css(eventName === "foreColor" ? 'color' : 'backgroundColor'); + }, _createPalette() { const $dropdownContent = this.toolbar.$el.find('#colorInputButtonGroup .colorPalette'); // The editor's root widget can be website or web's root widget and cannot be properly retrieved... @@ -1081,33 +1106,24 @@ const Wysiwyg = Widget.extend({ mutex.exec(() => { const oldColorpicker = colorpicker; const hookEl = oldColorpicker ? oldColorpicker.el : elem; - + const selectedColor = this._getSelectedColor($, eventName); const selection = this.odooEditor.document.getSelection(); const range = selection.rangeCount && selection.getRangeAt(0); - const targetNode = range && range.startContainer; - 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; - } - } const hadNonCollapsedSelection = range && !selection.isCollapsed; 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: backgroundGradient || $(targetElement).css(eventName === "foreColor" ? 'color' : 'backgroundColor'), + selectedColor: selectedColor, + selectedTab: weUtils.isColorGradient(selectedColor) ? 'gradients' : 'theme-colors', withGradients: true, }); + this.colorpickers[eventName] = colorpicker; colorpicker.on('custom_color_picked color_picked', null, ev => { if (hadNonCollapsedSelection) { this.odooEditor.historyResetLatestComputedSelection(true); } this._processAndApplyColor(eventName, ev.data.color); + this._updateEditorUI(); }); colorpicker.on('color_hover color_leave', null, ev => { if (hadNonCollapsedSelection) { @@ -1309,6 +1325,15 @@ const Wysiwyg = Widget.extend({ setTimeout(() => $target.tooltip('dispose'), 800); }, 400); } + // Update color of already opened colorpickers. + for (let eventName in this.colorpickers) { + const selectedColor = this._getSelectedColor($, eventName); + if (selectedColor) { + // If the palette was already opened (e.g. modifying a gradient), the new DOM state + // must be reflected in the palette, but the tab selection must not be impacted. + this.colorpickers[eventName].setSelectedColor(null, selectedColor, false); + } + } }, _updateMediaJustifyButton: function (commandState) { if (!this.lastMediaClicked) { diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index f7e90ad3c31..459fad65679 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -1709,6 +1709,8 @@ body.editor_enable.editor_has_snippets { } .o_colorpicker_reset { @extend %we-generic-button; + border: 0; + background-color: transparent; } .o_colorpicker_sections { @@ -1750,6 +1752,97 @@ body.editor_enable.editor_has_snippets { bottom: -1px; } } + .o_custom_gradient_editor { + font-size: 12px; + + .o_custom_gradient_btn { + color: #ffffff; + background-color: $o-we-sidebar-content-field-colorpicker-dropdown-bg; + // Needed when nested inside toolbar. + float: none; + box-sizing: border-box; + } + span.o_custom_gradient_input { + border: 1px solid black; + input { + outline: none; + } + &:focus-within { + border-color: $o-we-sidebar-content-field-input-border-color; + } + } + .o_custom_gradient_scale { + @extend %o-preview-alpha-background; + cursor: copy; + div { + height: 20px; + } + } + .o_slider_multi { + display: grid; + + input[type=range] { + pointer-events: none; + focusable: false; + grid-column: 1/span 2; + grid-row: 3; + background: none; + -webkit-appearance: none; + cursor: ew-resize; + @supports (-moz-appearance:none) { + margin-top: 2px; + } + + &::-webkit-slider-thumb { + pointer-events: auto; + border: 1.5px solid rgba(255, 255, 255, 0.8); + background: currentColor; + -webkit-appearance: none; + box-shadow: 0px 0px 0px #000000; + height: 20px; + width: 12px; + border-radius: 5px; + } + + &.active::-webkit-slider-thumb { + border-color: $o-we-accent; + } + + &::-moz-range-thumb { + pointer-events: auto; + border: 1.5px solid rgba(255, 255, 255, 0.8); + background: currentColor; + box-shadow: 0px 0px 0px #000000; + height: 18px; + width: 10px; + border-radius: 5px; + margin-top: 3px; + } + + &.active::-moz-range-thumb { + border-color: $o-we-accent; + } + + &::-ms-thumb { + pointer-events: auto; + border: 1.5px solid rgba(255, 255, 255, 0.8); + background: currentColor; + box-shadow: 0px 0px 0px #000000; + height: 20px; + width: 12px; + border-radius: 5px; + } + + &.active::-ms-thumb { + border-color: $o-we-accent; + } + } + } + .o_remove_color { + font-size: 14px !important; + text-align: center !important; + } + } } } diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml index 8049765e898..e4aa11f69d1 100644 --- a/addons/web_editor/static/src/xml/snippets.xml +++ b/addons/web_editor/static/src/xml/snippets.xml @@ -58,7 +58,7 @@ NoneReset - diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml index cfc93e79176..6b9a4d577e5 100644 --- a/addons/web_editor/views/editor.xml +++ b/addons/web_editor/views/editor.xml @@ -75,6 +75,94 @@ +
+ +
+ Type + + Linear + Radial + +
+
+ Angle + + + deg + +
+
+ Position + + X + + + % + + Y + + + % + + +
+
+ Size + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
diff --git a/addons/website/static/tests/tours/snippet_background_edition.js b/addons/website/static/tests/tours/snippet_background_edition.js index 123ae91be58..94a639e00ee 100644 --- a/addons/website/static/tests/tours/snippet_background_edition.js +++ b/addons/website/static/tests/tours/snippet_background_edition.js @@ -15,6 +15,43 @@ const gradients = [ 'linear-gradient(135deg, rgb(255, 222, 202) 0%, rgb(202, 115, 69) 100%)', ]; +function typeToName(xType) { + return xType === 'cc' ? 'color combinations' : xType === 'bg' ? 'background colors' : 'gradients'; +} + +function switchTo(type, _name) { + const target = type === 'cc' ? 'color-combinations' : type === 'bg' ? 'custom-colors' : 'gradients'; + const name = _name || typeToName(type); + return { + trigger: `.o_we_colorpicker_switch_pane_btn[data-target="${target}"]`, + content: `Switch to ${name}`, + }; +} + +function addCheck(steps, checkX, checkNoX, xType, noSwitch = false) { + if (!checkX && !checkNoX) { + return; + } + + const name = typeToName(xType); + const selectorCheckX = checkX && `.o_we_color_btn[data-color="${checkX}"].selected`; + const selectorCheckNoX = checkNoX && `.o_we_color_btn[data-color="${checkNoX}"]:not(.selected)`; + const step = { + trigger: selectorCheckX || selectorCheckNoX, + content: `The correct ${name} is marked as selected`, + position: 'bottom', + run: () => null, + }; + if (!selectorCheckX && selectorCheckNoX) { + step.extra_trigger = selectorCheckNoX; + } + + if (!noSwitch) { + steps.push(switchTo(xType, name)); + } + steps.push(step); +} + function checkAndUpdateBackgroundColor({ checkCC, checkNoCC, checkBg, checkNoBg, @@ -26,49 +63,12 @@ function checkAndUpdateBackgroundColor({ wTourUtils.changeBackgroundColor(), ]; - function typeToName(xType) { - return xType === 'cc' ? 'color combinations' : xType === 'bg' ? 'background colors' : 'gradients'; - } - - function switchTo(type, _name) { - const target = type === 'cc' ? 'color-combinations' : type === 'bg' ? 'custom-colors' : 'gradients'; - const name = _name || typeToName(type); - steps.push({ - trigger: `.o_we_colorpicker_switch_pane_btn[data-target="${target}"]`, - content: `Switch to ${name}`, - }); - } - - function check(checkX, checkNoX, xType, noSwitch = false) { - if (!checkX && !checkNoX) { - return; - } - - const name = typeToName(xType); - const selectorCheckX = checkX && `.o_we_color_btn[data-color="${checkX}"].selected`; - const selectorCheckNoX = checkNoX && `.o_we_color_btn[data-color="${checkNoX}"]:not(.selected)`; - const step = { - trigger: selectorCheckX || selectorCheckNoX, - content: `The correct ${name} is marked as selected`, - position: 'bottom', - run: () => null, - }; - if (!selectorCheckX && selectorCheckNoX) { - step.extra_trigger = selectorCheckNoX; - } - - if (!noSwitch) { - switchTo(xType, name); - } - steps.push(step); - } - - check(checkCC, checkNoCC, 'cc', true); - check(checkBg, checkNoBg, 'bg'); - check(checkGradient, checkNoGradient, 'gradient'); + addCheck(steps, checkCC, checkNoCC, 'cc', true); + addCheck(steps, checkBg, checkNoBg, 'bg'); + addCheck(steps, checkGradient, checkNoGradient, 'gradient'); if (changeType) { - switchTo(changeType); + steps.push(switchTo(changeType)); steps.push(wTourUtils.changeOption('ColoredLevelBackground', `.o_we_color_btn[data-color="${change}"]`)); steps.push({ trigger: finalSelector, @@ -81,6 +81,16 @@ function checkAndUpdateBackgroundColor({ return steps; } +function updateAndCheckCustomGradient({updateStep, checkGradient}) { + const steps = [updateStep, { + trigger: `#wrapwrap section.${snippets[0].id}.o_cc1`, + content: 'Color combination 1 still selected', + run: () => null, + }]; + addCheck(steps, checkGradient, checkGradient !== gradients[0] && gradients[0], 'gradient', true); + return steps; +} + tour.register('snippet_background_edition', { test: true, url: '/?enable_editor=1', @@ -208,6 +218,96 @@ wTourUtils.changeOption('ColoredLevelBackground', '[data-name="bg_image_toggle_o }, }), +// Customize gradient +wTourUtils.changeBackgroundColor(), +switchTo('gradient'), +// Avoid navigating across tabs to maintain current editor state +// Step colors +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker .o_custom_gradient_scale', + content: 'Add step', + run: 'click', + }, + checkGradient: 'linear-gradient(135deg, rgb(203, 94, 238) 0%, rgb(203, 94, 238) 0%, rgb(75, 225, 236) 100%)', +}), +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker .o_slider_multi input.active', + content: 'Move step', + run: () => { + const slider = $('.colorpicker .o_slider_multi input.active'); + slider.val(45); + slider.trigger('click'); + }, + }, + checkGradient: 'linear-gradient(135deg, rgb(203, 94, 238) 0%, rgb(203, 94, 238) 45%, rgb(75, 225, 236) 100%)', +}), +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker .o_color_picker_inputs .o_hex_div input', + content: 'Pick step color', + run: 'text #FF0000', + }, + checkGradient: 'linear-gradient(135deg, rgb(203, 94, 238) 0%, rgb(255, 0, 0) 45%, rgb(75, 225, 236) 100%)', +}), +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker .o_remove_color', + content: 'Delete step', + run: 'click', + }, + checkGradient: 'linear-gradient(135deg, rgb(203, 94, 238) 0%, rgb(75, 225, 236) 100%)', +}), +// Linear +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker input[data-name="angle"]', + content: 'Change angle', + run: 'text_blur 50', + }, + checkGradient: 'linear-gradient(50deg, rgb(203, 94, 238) 0%, rgb(75, 225, 236) 100%)', +}), +// Radial +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker we-button[data-gradient-type="radial-gradient"]', + content: 'Switch to Radial', + run: 'click', + }, + checkGradient: 'radial-gradient(circle farthest-side at 25% 25%, rgb(203, 94, 238) 0%, rgb(75, 225, 236) 100%)', +}), +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker input[data-name="positionX"]', + content: 'Change X position', + run: 'text_blur 33', + }, + checkGradient: 'radial-gradient(circle farthest-side at 33% 25%, rgb(203, 94, 238) 0%, rgb(75, 225, 236) 100%)', +}), +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker input[data-name="positionY"]', + content: 'Change Y position', + run: 'text_blur 75', + }, + checkGradient: 'radial-gradient(circle farthest-side at 33% 75%, rgb(203, 94, 238) 0%, rgb(75, 225, 236) 100%)', +}), +...updateAndCheckCustomGradient({ + updateStep: { + trigger: '.colorpicker we-button[data-gradient-size="closest-side"]', + content: 'Change color spread size', + run: 'click', + }, + checkGradient: 'radial-gradient(circle closest-side at 33% 75%, rgb(203, 94, 238) 0%, rgb(75, 225, 236) 100%)', +}), +// Revert to predefined gradient +{ + trigger: `.o_we_color_btn[data-color="${gradients[0]}"]`, + content: `Revert to predefiend gradient ${gradients[0]}`, + run: 'click', +}, + // Replace the gradient by a bg color ...checkAndUpdateBackgroundColor({ checkCC: 1,