diff --git a/addons/mass_mailing/__manifest__.py b/addons/mass_mailing/__manifest__.py index 06710b82b43..ea28dad1d80 100644 --- a/addons/mass_mailing/__manifest__.py +++ b/addons/mass_mailing/__manifest__.py @@ -46,7 +46,6 @@ 'views/res_config_settings_views.xml', 'views/utm_campaign_views.xml', 'views/mailing_menus.xml', - 'views/assets.xml', 'views/mailing_templates_portal_layouts.xml', 'views/mailing_templates_portal_management.xml', 'views/mailing_templates_portal_unsubscribe.xml', @@ -84,10 +83,27 @@ ], 'application': True, 'assets': { + 'mass_mailing.iframe_css_assets_edit': [ + ('include', 'mass_mailing.assets_mail_themes'), + ('include', 'web.assets_frontend'), + ('after', 'web/static/lib/bootstrap/scss/_variables.scss', 'mass_mailing/static/src/scss/mass_mailing.ui.scss'), + ('include', 'web_editor.assets_wysiwyg'), + ('include', 'web_editor.assets_legacy_wysiwyg'), + + 'mass_mailing/static/src/scss/mass_mailing_mail.scss', + ], + 'mass_mailing.iframe_css_assets_readonly': [ + 'mass_mailing/static/src/scss/mass_mailing_mail.scss', + 'mass_mailing/static/src/css/basic_theme_readonly.css' + ], 'mass_mailing.mailing_assets': [ 'mass_mailing/static/src/scss/mailing_portal.scss', 'mass_mailing/static/src/js/mailing_portal.js', ], + 'web_editor.assets_wysiwyg': [ + 'mass_mailing/static/src/js/mass_mailing_wysiwyg.js', + 'mass_mailing/static/src/scss/mass_mailing.wysiwyg.scss', + ], 'web.assets_backend': [ 'mass_mailing/static/src/scss/mailing_filter_widget.scss', 'mass_mailing/static/src/scss/mass_mailing.scss', @@ -112,18 +128,14 @@ 'mass_mailing/static/src/scss/mass_mailing.ui.scss', ], 'mass_mailing.assets_wysiwyg': [ - 'mass_mailing/static/src/js/mass_mailing_link_dialog_fix.js', 'mass_mailing/static/src/js/mass_mailing_snippets.js', 'mass_mailing/static/src/snippets/s_masonry_block/options.js', 'mass_mailing/static/src/snippets/s_media_list/options.js', 'mass_mailing/static/src/snippets/s_showcase/options.js', 'mass_mailing/static/src/snippets/s_rating/options.js' ], - 'web_editor.assets_wysiwyg': [ + 'web_editor.assets_legacy_wysiwyg': [ 'mass_mailing/static/src/js/snippets.editor.js', - 'mass_mailing/static/src/js/wysiwyg.js', - 'mass_mailing/static/src/xml/mass_mailing.editor.xml', - 'mass_mailing/static/src/scss/mass_mailing.wysiwyg.scss', ], 'web.assets_common': [ 'mass_mailing/static/src/js/tours/**/*', diff --git a/addons/mass_mailing/data/mailing_data_templates.xml b/addons/mass_mailing/data/mailing_data_templates.xml index 53baaf0c224..5c2e7d1ee35 100644 --- a/addons/mass_mailing/data/mailing_data_templates.xml +++ b/addons/mass_mailing/data/mailing_data_templates.xml @@ -14,7 +14,7 @@ - + - - - - - - diff --git a/addons/mass_mailing/wizard/mail_compose_message.py b/addons/mass_mailing/wizard/mail_compose_message.py index 70bc9d72c40..937d3482d79 100644 --- a/addons/mass_mailing/wizard/mail_compose_message.py +++ b/addons/mass_mailing/wizard/mail_compose_message.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from markupsafe import Markup + from odoo import fields, models +from odoo.tools.misc import file_open class MailComposeMessage(models.TransientModel): @@ -34,11 +37,13 @@ class MailComposeMessage(models.TransientModel): return mail_values_all trace_values_all = self._prepare_mail_values_mailing_traces(mail_values_all) + with file_open("mass_mailing/static/src/scss/mass_mailing_mail.scss", "r") as fd: + styles = fd.read() for res_id, mail_values in mail_values_all.items(): if mail_values.get('body_html'): body = self.env['ir.qweb']._render( 'mass_mailing.mass_mailing_mail_layout', - {'body': mail_values['body_html']}, + {'body': mail_values['body_html'], 'mailing_style': Markup(f'')}, minimal_qcontext=True, raise_if_not_found=False ) diff --git a/addons/mass_mailing/wizard/mailing_mailing_test.py b/addons/mass_mailing/wizard/mailing_mailing_test.py index 5df68d198bd..d450f27442c 100644 --- a/addons/mass_mailing/wizard/mailing_mailing_test.py +++ b/addons/mass_mailing/wizard/mailing_mailing_test.py @@ -4,6 +4,7 @@ from markupsafe import Markup from odoo import _, fields, models, tools +from odoo.tools.misc import file_open class TestMassMailing(models.TransientModel): @@ -49,13 +50,18 @@ class TestMassMailing(models.TransientModel): # Convert links in absolute URLs before the application of the shortener full_body = self.env['mail.render.mixin']._replace_local_links(full_body) + with file_open("mass_mailing/static/src/scss/mass_mailing_mail.scss", "r") as fd: + styles = fd.read() for valid_email in valid_emails: mail_values = { 'email_from': mailing.email_from, 'reply_to': mailing.reply_to, 'email_to': valid_email, 'subject': subject, - 'body_html': self.env['ir.qweb']._render('mass_mailing.mass_mailing_mail_layout', {'body': full_body}, minimal_qcontext=True), + 'body_html': self.env['ir.qweb']._render('mass_mailing.mass_mailing_mail_layout', { + 'body': full_body, + 'mailing_style': Markup(f''), + }, minimal_qcontext=True), 'is_notification': True, 'mailing_id': mailing.id, 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index 9c8dd58b15e..9c044756702 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -187,7 +187,6 @@ 'web_editor/static/src/scss/web_editor.common.scss', 'web_editor/static/src/scss/web_editor.backend.scss', - 'web_editor/static/src/js/wysiwyg/dialog.js', 'web_editor/static/src/js/frontend/loader.js', 'web_editor/static/src/js/backend/**/*', 'web_editor/static/src/xml/backend.xml', diff --git a/addons/test_website/static/tests/field_html_file_upload.js b/addons/test_website/static/tests/field_html_file_upload.js index a0bb612cbad..395f67ef9c3 100644 --- a/addons/test_website/static/tests/field_html_file_upload.js +++ b/addons/test_website/static/tests/field_html_file_upload.js @@ -9,7 +9,7 @@ import { uploadService } from '@web_editor/components/upload_progress_toast/uplo import { unsplashService } from '@web_unsplash/services/unsplash_service'; import { createWebClient, doAction } from "@web/../tests/webclient/helpers"; import weTestUtils from 'web_editor.test_utils'; -import Wysiwyg from 'web_editor.wysiwyg'; +import { Wysiwyg } from '@web_editor/js/wysiwyg/wysiwyg'; const { useEffect } = owl; @@ -65,6 +65,12 @@ QUnit.module('field html file upload', { onChangeTriggered.resolve(true); } }); + patchWithCleanup(Wysiwyg.prototype, { + async _getColorpickerTemplate() { + return weTestUtils.COLOR_PICKER_TEMPLATE; + } + }); + // create and load form view const serviceRegistry = registry.category("services"); serviceRegistry.add("upload", uploadService); diff --git a/addons/web/__manifest__.py b/addons/web/__manifest__.py index 9e2d38335c5..65cb5f57358 100644 --- a/addons/web/__manifest__.py +++ b/addons/web/__manifest__.py @@ -38,7 +38,7 @@ This module provides the core of the Odoo Web Client. # Examples: # > web.assets_common = assets common to backend clients and others # (not frontend). - # > web_editor.assets_wysiwyg = assets needed by components defined in the "web_editor" module. + # > web_editor.assets_legacy_wysiwyg = assets needed by components defined in the "web_editor" module. # Warning: Layouts using "assets_frontend" assets do not have the # "assets_common" assets anymore. So, if it make sense, files added in @@ -159,8 +159,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/js/widgets/name_and_signature.js', 'web/static/src/legacy/xml/name_and_signature.xml', 'web/static/src/legacy/js/core/smooth_scroll_on_drag.js', - 'web/static/src/legacy/js/widgets/colorpicker.js', - 'web/static/src/legacy/xml/colorpicker.xml', 'web/static/src/legacy/translations_loaded.js', ], 'web.assets_emoji': [ @@ -244,6 +242,7 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/js/services/session.js', 'web/static/src/legacy/js/tools/tools.js', 'web/static/src/legacy/js/views/**/*', + 'web/static/src/legacy/js/widgets/colorpicker_dialog.js', 'web/static/src/legacy/js/widgets/date_picker.js', 'web/static/src/legacy/js/widgets/domain_selector_dialog.js', 'web/static/src/legacy/js/widgets/domain_selector.js', @@ -392,8 +391,6 @@ This module provides the core of the Odoo Web Client. 'web/static/src/legacy/js/widgets/name_and_signature.js', 'web/static/src/legacy/xml/name_and_signature.xml', 'web/static/src/legacy/js/core/smooth_scroll_on_drag.js', - 'web/static/src/legacy/js/widgets/colorpicker.js', - 'web/static/src/legacy/xml/colorpicker.xml', 'web/static/src/env.js', 'web/static/src/core/utils/transitions.scss', # included early because used by other files diff --git a/addons/web/static/src/core/colorpicker/colorpicker.js b/addons/web/static/src/core/colorpicker/colorpicker.js new file mode 100644 index 00000000000..d0c71b63a29 --- /dev/null +++ b/addons/web/static/src/core/colorpicker/colorpicker.js @@ -0,0 +1,525 @@ +/** @odoo-module **/ + +import { + convertCSSColorToRgba, + convertRgbaToCSSColor, + convertRgbToHsl, + convertHslToRgb, +} from '@web/core/utils/colors'; + +import { + Component, + useRef, + onWillStart, + onMounted, + onWillUpdateProps, + onWillDestroy, +} from "@odoo/owl"; +import { uniqueId } from "@web/core/utils/functions"; +import { debounce, throttleForAnimation } from "@web/core/utils/timing"; + + +/** + * Confines a value inside an interval + * + * @param {number} [val] the value to confine + * @param {number} [min] the minimum of the interval + * @param {number} [max] the maximum of the interval + * @return {number} val if val is in [min, max], min if val < min and max + * otherwise + */ +function confine(val, min, max) { + return Math.max(min, Math.min(max, val)); +} + +export class Colorpicker extends Component { + static template = 'web.Colorpicker'; + static props = { + document: { type: true, optional: true }, + defaultColor: { type: String, optional: true }, + selectedColor: { type: String, optional: true }, + noTransparency: { type: Boolean, optional: true }, + colorPreview: { type: Boolean, optional: true }, + stopClickPropagation: { type: Boolean, optional: true }, + onColorSelect: { type: Function, optional: true }, + onColorPreview: { type: Function, optional: true }, + onInputEnter: { type: Function, optional: true }, + }; + static defaultProps = { + document: window.document, + defaultColor: '#FF0000', + noTransparency: false, + colorPreview: false, + stopClickPropagation: false, + onColorSelect: () => {}, + onColorPreview: () => {}, + onInputEnter: () => {}, + }; + + elRef = useRef('el'); + + setup() { + onWillStart(() => { + this.init(); + }); + onMounted(async () => { + if (!this.elRef.el) { + // There is legacy code that can trigger the instantiation of the + // link tool when one of it's parent component is not in the dom. If + // that parent element is not in the dom, owl will not return + // `this.linkComponentWrapperRef.el` because of a check (see + // `inOwnerDocument`). + // Todo: this workaround should be removed when the snippet menu is + // converted to owl. + await new Promise(resolve => { + const observer = new MutationObserver(() => { + if (this.elRef.el) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); + } + this.el = this.elRef.el; + this.$el = $(this.el); + + this.$el.on('click', this._onClick.bind(this)); + this.$el.on('keypress', this._onKeypress.bind(this)); + this.$el.on('mousedown', '.o_color_pick_area', this._onMouseDownPicker.bind(this)); + this.$el.on('mousedown', '.o_color_slider', this._onMouseDownSlider.bind(this)); + this.$el.on('mousedown', '.o_opacity_slider', this._onMouseDownOpacitySlider.bind(this)); + this.$el.on('change', '.o_color_picker_inputs', this._onChangeInputs.bind(this)); + + this.start(); + }); + onWillUpdateProps((newProps) => { + if (newProps.selectedColor) { + this.setSelectedColor(newProps.selectedColor); + } + }); + onWillDestroy(() => { + this.destroy(); + }); + } + + init() { + this.pickerFlag = false; + this.sliderFlag = false; + this.opacitySliderFlag = false; + this.colorComponents = {}; + this.uniqueId = uniqueId("colorpicker"); + this.selectedHexValue = ''; + } + /** + * @override + */ + start() { + this.$colorpickerArea = this.$el.find('.o_color_pick_area'); + this.$colorpickerPointer = this.$el.find('.o_picker_pointer'); + this.$colorSlider = this.$el.find('.o_color_slider'); + this.$colorSliderPointer = this.$el.find('.o_slider_pointer'); + this.$opacitySlider = this.$el.find('.o_opacity_slider'); + this.$opacitySliderPointer = this.$el.find('.o_opacity_pointer'); + + const rgba = convertCSSColorToRgba(this.props.defaultColor); + if (rgba) { + this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); + } + + // Pre-fill the inputs. This is because on safari, the baseline for empty + // input is not the baseline of where the text would be, but the bottom + // of the input itself. (see https://bugs.webkit.org/show_bug.cgi?id=142968) + // This will cause the first _updateUI to alter the layout of the colorpicker + // which will change its height. Changing the height of an element inside of + // the callback to a ResizeObserver observing it will cause an error + // (ResizeObserver loop completed with undelivered notifications) that cannot + // be caught, which will open the crash manager. Prefilling the inputs sets + // the baseline correctly from the start so the layout doesn't change. + Object.entries(this.colorComponents).forEach(([component, value]) => { + const input = this.el.querySelector(`.o_${component}_input`); + if (input) { + input.value = value; + } + }); + const resizeObserver = new window.ResizeObserver(() => { + this._updateUI(); + }); + resizeObserver.observe(this.el); + + // Need to be bound on all documents to work in all possible cases (we + // have to be able to start dragging/moving from the colorpicker to + // anywhere on the screen, crossing iframes). + this.$documents = $([window.top, ...Array.from(window.top.frames).filter(frame => { + try { + const document = frame.document; + return !!document; + } catch { + // We cannot access the document (cross origin). + return false; + } + })].map(w => w.document)); + this.throttleOnMouseMove = throttleForAnimation((ev) => { + this._onMouseMovePicker(ev); + this._onMouseMoveSlider(ev); + this._onMouseMoveOpacitySlider(ev); + }); + this.$documents.on(`mousemove.${this.uniqueId}`, this.throttleOnMouseMove); + this.$documents.on(`mouseup.${this.uniqueId}`, debounce((ev) => { + if (this.pickerFlag || this.sliderFlag || this.opacitySliderFlag) { + this._colorSelected(); + } + this.pickerFlag = false; + this.sliderFlag = false; + this.opacitySliderFlag = false; + }, 10)); + + this.previewActive = true; + } + /** + * @override + */ + destroy() { + if (this.throttleOnMouseMove) { + this.$documents.off(`.${this.uniqueId}`); + this.throttleOnMouseMove.cancel(); + } + } + /** + * Sets the currently selected color + * + * @param {string} color rgb[a] + */ + setSelectedColor(color) { + const rgba = 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(); + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Updates input values, color preview, picker and slider pointer positions. + * + * @private + */ + _updateUI() { + // Update inputs + for (const [color, value] of Object.entries(this.colorComponents)) { + this.$el.find(`.o_${color}_input`).val(value); + } + + // Update preview + this.$el.find('.o_color_preview').css('background-color', this.colorComponents.cssColor); + + // Update picker area and picker pointer position + this.$colorpickerArea.css( + "background-color", + `hsl(${this.colorComponents.hue}, 100%, 50%)` + ); + const top = (100 - this.colorComponents.lightness) * this.$colorpickerArea.height() / 100; + const left = this.colorComponents.saturation * this.$colorpickerArea.width() / 100; + this.$colorpickerPointer.css({ + top: (top - 5) + 'px', + left: (left - 5) + 'px', + }); + + // Update color slider position + const height = this.$colorSlider.height(); + const y = this.colorComponents.hue * height / 360; + this.$colorSliderPointer.css('top', Math.round(y - 2)); + + if (!this.props.noTransparency) { + // Update opacity slider position + const heightOpacity = this.$opacitySlider.height(); + const z = heightOpacity * (1 - this.colorComponents.opacity / 100.0); + this.$opacitySliderPointer.css('top', Math.round(z - 2)); + + // Add gradient color on opacity slider + this.$opacitySlider.css('background', 'linear-gradient(' + this.colorComponents.hex + ' 0%, transparent 100%)'); + } + } + /** + * Updates colors according to given hex value. Opacity is left unchanged. + * + * @private + * @param {string} hex - hexadecimal code + */ + _updateHex(hex) { + const rgb = convertCSSColorToRgba(hex); + if (!rgb) { + return; + } + Object.assign(this.colorComponents, + {hex: hex}, + rgb, + convertRgbToHsl(rgb.red, rgb.green, rgb.blue) + ); + this._updateCssColor(); + } + /** + * Updates colors according to given RGB values. + * + * @private + * @param {integer} r + * @param {integer} g + * @param {integer} b + * @param {integer} [a] + */ + _updateRgba(r, g, b, a) { + // Remove full transparency in case some lightness is added + const opacity = a || this.colorComponents.opacity; + if (opacity < 0.1 && (r > 0.1 || g > 0.1 || b > 0.1)) { + a = 100; + } + + // We update the hexadecimal code by transforming into a css color and + // ignoring the opacity (we don't display opacity component in hexa as + // not supported on all browsers) + const hex = convertRgbaToCSSColor(r, g, b); + if (!hex) { + return; + } + Object.assign(this.colorComponents, + {red: r, green: g, blue: b}, + a === undefined ? {} : {opacity: a}, + {hex: hex}, + convertRgbToHsl(r, g, b) + ); + this._updateCssColor(); + } + /** + * Updates colors according to given HSL values. + * + * @private + * @param {integer} h + * @param {integer} s + * @param {integer} l + */ + _updateHsl(h, s, l) { + // Remove full transparency in case some lightness is added + let a = this.colorComponents.opacity; + if (a < 0.1 && l > 0.1) { + a = 100; + } + + const rgb = convertHslToRgb(h, s, l); + if (!rgb) { + return; + } + // We receive an hexa as we ignore the opacity + const hex = convertRgbaToCSSColor(rgb.red, rgb.green, rgb.blue); + Object.assign(this.colorComponents, + {hue: h, saturation: s, lightness: l}, + rgb, + {hex: hex}, + {opacity: a}, + ); + this._updateCssColor(); + } + /** + * Updates color opacity. + * + * @private + * @param {integer} a + */ + _updateOpacity(a) { + if (a < 0 || a > 100) { + return; + } + Object.assign(this.colorComponents, + {opacity: a} + ); + this._updateCssColor(); + } + /** + * Trigger an event to annonce that the widget value has changed + * + * @private + */ + _colorSelected() { + this.props.onColorSelect(this.colorComponents); + } + /** + * Updates css color representation. + * + * @private + */ + _updateCssColor() { + const r = this.colorComponents.red; + const g = this.colorComponents.green; + const b = this.colorComponents.blue; + const a = this.colorComponents.opacity; + Object.assign(this.colorComponents, + {cssColor: convertRgbaToCSSColor(r, g, b, a)} + ); + if (this.previewActive) { + this.props.onColorPreview(this.colorComponents); + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onKeypress(ev) { + if (ev.charCode === $.ui.keyCode.ENTER) { + if (ev.target.tagName === 'INPUT') { + this._onChangeInputs(ev); + } + ev.preventDefault(); + this.props.onInputEnter(ev); + } + } + /** + * @param {Event} ev + */ + _onClick(ev) { + if (this.props.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) { + ev.target.select(); + this.selectedHexValue = ev.target.value; + return; + } + this.selectedHexValue = ''; + } + /** + * Updates color when the user starts clicking on the picker. + * + * @private + * @param {Event} ev + */ + _onMouseDownPicker(ev) { + this.pickerFlag = true; + ev.preventDefault(); + this._onMouseMovePicker(ev); + } + /** + * Updates saturation and lightness values on mouse drag over picker. + * + * @private + * @param {Event} ev + */ + _onMouseMovePicker(ev) { + if (!this.pickerFlag) { + return; + } + + const offset = this.$colorpickerArea.offset(); + const top = ev.pageY - offset.top; + const left = ev.pageX - offset.left; + let saturation = Math.round(100 * left / this.$colorpickerArea.width()); + let lightness = Math.round(100 * (this.$colorpickerArea.height() - top) / this.$colorpickerArea.height()); + saturation = confine(saturation, 0, 100); + lightness = confine(lightness, 0, 100); + + this._updateHsl(this.colorComponents.hue, saturation, lightness); + this._updateUI(); + } + /** + * Updates color when user starts clicking on slider. + * + * @private + * @param {Event} ev + */ + _onMouseDownSlider(ev) { + this.sliderFlag = true; + ev.preventDefault(); + this._onMouseMoveSlider(ev); + } + /** + * Updates hue value on mouse drag over slider. + * + * @private + * @param {Event} ev + */ + _onMouseMoveSlider(ev) { + if (!this.sliderFlag) { + return; + } + + const y = ev.pageY - this.$colorSlider.offset().top; + let hue = Math.round(360 * y / this.$colorSlider.height()); + hue = confine(hue, 0, 360); + + this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness); + this._updateUI(); + } + /** + * Updates opacity when user starts clicking on opacity slider. + * + * @private + * @param {Event} ev + */ + _onMouseDownOpacitySlider(ev) { + this.opacitySliderFlag = true; + ev.preventDefault(); + this._onMouseMoveOpacitySlider(ev); + } + /** + * Updates opacity value on mouse drag over opacity slider. + * + * @private + * @param {Event} ev + */ + _onMouseMoveOpacitySlider(ev) { + if (!this.opacitySliderFlag || this.props.noTransparency) { + return; + } + + const y = ev.pageY - this.$opacitySlider.offset().top; + let opacity = Math.round(100 * (1 - y / this.$opacitySlider.height())); + opacity = confine(opacity, 0, 100); + + this._updateOpacity(opacity); + this._updateUI(); + } + /** + * Called when input value is changed -> Updates UI: Set picker and slider + * position and set colors. + * + * @private + * @param {Event} ev + */ + _onChangeInputs(ev) { + switch ($(ev.target).data('colorMethod')) { + case 'hex': + this._updateHex(this.$el.find('.o_hex_input').val()); + break; + case 'rgb': + this._updateRgba( + parseInt(this.$el.find('.o_red_input').val()), + parseInt(this.$el.find('.o_green_input').val()), + parseInt(this.$el.find('.o_blue_input').val()) + ); + break; + case 'hsl': + this._updateHsl( + parseInt(this.$el.find('.o_hue_input').val()), + parseInt(this.$el.find('.o_saturation_input').val()), + parseInt(this.$el.find('.o_lightness_input').val()) + ); + break; + case 'opacity': + this._updateOpacity(parseInt(this.$el.find('.o_opacity_input').val())); + break; + } + this._updateUI(); + this._colorSelected(); + } +} diff --git a/addons/web/static/src/legacy/xml/colorpicker.xml b/addons/web/static/src/core/colorpicker/colorpicker.xml similarity index 86% rename from addons/web/static/src/legacy/xml/colorpicker.xml rename to addons/web/static/src/core/colorpicker/colorpicker.xml index 9b819160238..6f8dec7349c 100644 --- a/addons/web/static/src/legacy/xml/colorpicker.xml +++ b/addons/web/static/src/core/colorpicker/colorpicker.xml @@ -1,20 +1,20 @@ - -
+ +
-
+
-
+
-
+
@@ -26,7 +26,7 @@ - +
+ diff --git a/addons/web/static/src/core/utils/colors.js b/addons/web/static/src/core/utils/colors.js new file mode 100644 index 00000000000..a06357dcfe7 --- /dev/null +++ b/addons/web/static/src/core/utils/colors.js @@ -0,0 +1,248 @@ +/** @odoo-module **/ + +/** + * Converts RGB color components to HSL components. + * + * @static + * @param {integer} r - [0, 255] + * @param {integer} g - [0, 255] + * @param {integer} b - [0, 255] + * @returns {Object|false} + * - hue [0, 360[ (float) + * - saturation [0, 100] (float) + * - lightness [0, 100] (float) + */ +export function convertRgbToHsl(r, g, b) { + if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 + || typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 + || typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) { + return false; + } + + var red = r / 255; + var green = g / 255; + var blue = b / 255; + var maxColor = Math.max(red, green, blue); + var minColor = Math.min(red, green, blue); + var delta = maxColor - minColor; + var hue = 0; + var saturation = 0; + var lightness = (maxColor + minColor) / 2; + if (delta) { + if (maxColor === red) { + hue = (green - blue) / delta; + } + if (maxColor === green) { + hue = 2 + (blue - red) / delta; + } + if (maxColor === blue) { + hue = 4 + (red - green) / delta; + } + if (maxColor) { + saturation = delta / (1 - Math.abs(2 * lightness - 1)); + } + } + hue = 60 * hue; + return { + hue: hue < 0 ? hue + 360 : hue, + saturation: saturation * 100, + lightness: lightness * 100, + }; +}; +/** + * Converts HSL color components to RGB components. + * + * @static + * @param {number} h - [0, 360[ (float) + * @param {number} s - [0, 100] (float) + * @param {number} l - [0, 100] (float) + * @returns {Object|false} + * - red [0, 255] (integer) + * - green [0, 255] (integer) + * - blue [0, 255] (integer) + */ +export function convertHslToRgb(h, s, l) { + if (typeof (h) !== 'number' || isNaN(h) || h < 0 || h > 360 + || typeof (s) !== 'number' || isNaN(s) || s < 0 || s > 100 + || typeof (l) !== 'number' || isNaN(l) || l < 0 || l > 100) { + return false; + } + + var huePrime = h / 60; + var saturation = s / 100; + var lightness = l / 100; + var chroma = saturation * (1 - Math.abs(2 * lightness - 1)); + var secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1)); + var lightnessAdjustment = lightness - chroma / 2; + var precision = 255; + chroma = Math.round((chroma + lightnessAdjustment) * precision); + secondComponent = Math.round((secondComponent + lightnessAdjustment) * precision); + lightnessAdjustment = Math.round((lightnessAdjustment) * precision); + if (huePrime >= 0 && huePrime < 1) { + return { + red: chroma, + green: secondComponent, + blue: lightnessAdjustment, + }; + } + if (huePrime >= 1 && huePrime < 2) { + return { + red: secondComponent, + green: chroma, + blue: lightnessAdjustment, + }; + } + if (huePrime >= 2 && huePrime < 3) { + return { + red: lightnessAdjustment, + green: chroma, + blue: secondComponent, + }; + } + if (huePrime >= 3 && huePrime < 4) { + return { + red: lightnessAdjustment, + green: secondComponent, + blue: chroma, + }; + } + if (huePrime >= 4 && huePrime < 5) { + return { + red: secondComponent, + green: lightnessAdjustment, + blue: chroma, + }; + } + if (huePrime >= 5 && huePrime <= 6) { + return { + red: chroma, + green: lightnessAdjustment, + blue: secondComponent, + }; + } + return false; +}; +/** + * Converts RGBA color components to a normalized CSS color: if the opacity + * is invalid or equal to 100, a hex is returned; otherwise a rgba() css color + * is returned. + * + * Those choice have multiple reason: + * - A hex color is more common to c/c from other utilities on the web and is + * also shorter than rgb() css colors + * - Opacity in hexadecimal notations is not supported on all browsers and is + * also less common to use. + * + * @static + * @param {integer} r - [0, 255] + * @param {integer} g - [0, 255] + * @param {integer} b - [0, 255] + * @param {float} a - [0, 100] + * @returns {string} + */ +export function convertRgbaToCSSColor(r, g, b, a) { + if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 + || typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 + || typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) { + return false; + } + if (typeof (a) !== 'number' || isNaN(a) || a < 0 || Math.abs(a - 100) < Number.EPSILON) { + const rr = r < 16 ? '0' + r.toString(16) : r.toString(16); + const gg = g < 16 ? '0' + g.toString(16) : g.toString(16); + const bb = b < 16 ? '0' + b.toString(16) : b.toString(16); + return (`#${rr}${gg}${bb}`).toUpperCase(); + } + return `rgba(${r}, ${g}, ${b}, ${parseFloat((a / 100.0).toFixed(3))})`; +}; +/** + * Converts a CSS color (rgb(), rgba(), hexadecimal) to RGBA color components. + * + * Note: we don't support using and displaying hexadecimal color with opacity + * but this method allows to receive one and returns the correct opacity value. + * + * @static + * @param {string} cssColor - hexadecimal code or rgb() or rgba() + * @returns {Object|false} + * - red [0, 255] (integer) + * - green [0, 255] (integer) + * - blue [0, 255] (integer) + * - opacity [0, 100.0] (float) + */ +export function convertCSSColorToRgba(cssColor) { + // Check if cssColor is a rgba() or rgb() color + const rgba = cssColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); + if (rgba) { + if (rgba[4] === undefined) { + rgba[4] = 1; + } + return { + red: parseInt(rgba[1]), + green: parseInt(rgba[2]), + blue: parseInt(rgba[3]), + opacity: Math.round(parseFloat(rgba[4]) * 100), + }; + } + + // Otherwise, check if cssColor is an hexadecimal code color + if (/^#([0-9A-F]{6}|[0-9A-F]{8})$/i.test(cssColor)) { + return { + red: parseInt(cssColor.substr(1, 2), 16), + green: parseInt(cssColor.substr(3, 2), 16), + blue: parseInt(cssColor.substr(5, 2), 16), + opacity: (cssColor.length === 9 ? (parseInt(cssColor.substr(7, 2), 16) / 255) : 1) * 100, + }; + } + + // TODO maybe implement a support for receiving css color like 'red' or + // 'transparent' (which are now considered non-css color by isCSSColor...) + // Note: however, if ever implemented be careful of 'white'/'black' which + // actually are color names for our color system... + + return false; +}; +/** + * Converts a CSS color (rgb(), rgba(), hexadecimal) to a normalized version + * of the same color (@see convertRgbaToCSSColor). + * + * Normalized color can be safely compared using string comparison. + * + * @static + * @param {string} cssColor - hexadecimal code or rgb() or rgba() + * @returns {string} - the normalized css color or the given css color if it + * failed to be normalized + */ +export function normalizeCSSColor(cssColor) { + const rgba = convertCSSColorToRgba(cssColor); + if (!rgba) { + return cssColor; + } + return convertRgbaToCSSColor(rgba.red, rgba.green, rgba.blue, rgba.opacity); +}; +/** + * Checks if a given string is a css color. + * + * @static + * @param {string} cssColor + * @returns {boolean} + */ +export function isCSSColor(cssColor) { + return convertCSSColorToRgba(cssColor) !== false; +}; +/** + * Mixes two colors by applying a weighted average of their red, green and blue + * components. + * + * @static + * @param {string} cssColor1 - hexadecimal code or rgb() or rgba() + * @param {string} cssColor2 - hexadecimal code or rgb() or rgba() + * @param {number} weight - a number between 0 and 1 + * @returns {string} - mixed color in hexadecimal format + */ +export function mixCssColors(cssColor1, cssColor2, weight) { + const rgba1 = convertCSSColorToRgba(cssColor1); + const rgba2 = convertCSSColorToRgba(cssColor2); + const rgb1 = [rgba1.red, rgba1.green, rgba1.blue]; + const rgb2 = [rgba2.red, rgba2.green, rgba2.blue]; + const [r, g, b] = rgb1.map((_, idx) => Math.round(rgb2[idx] + (rgb1[idx] - rgb2[idx]) * weight)); + return convertRgbaToCSSColor(r, g, b); +}; diff --git a/addons/web/static/src/legacy/js/widgets/colorpicker.js b/addons/web/static/src/legacy/js/widgets/colorpicker.js deleted file mode 100644 index c87eb8a2544..00000000000 --- a/addons/web/static/src/legacy/js/widgets/colorpicker.js +++ /dev/null @@ -1,756 +0,0 @@ -/** @odoo-module alias=web.Colorpicker **/ - -import core from "web.core"; -import utils from "web.utils"; -import Dialog from "web.Dialog"; -import Widget from "web.Widget"; -import { uniqueId } from "@web/core/utils/functions"; -import { debounce, throttleForAnimation } from "@web/core/utils/timing"; - -var _t = core._t; - -var ColorpickerWidget = Widget.extend({ - template: 'Colorpicker', - events: { - 'click': '_onClick', - 'keypress': '_onKeypress', - 'mousedown .o_color_pick_area': '_onMouseDownPicker', - 'mousedown .o_color_slider': '_onMouseDownSlider', - 'mousedown .o_opacity_slider': '_onMouseDownOpacitySlider', - 'change .o_color_picker_inputs': '_onChangeInputs', - }, - - /** - * @constructor - * @param {Widget} parent - * @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); - options = options || {}; - - this.pickerFlag = false; - this.sliderFlag = false; - this.opacitySliderFlag = false; - this.colorComponents = {}; - this.uniqueId = uniqueId("colorpicker"); - this.selectedHexValue = ''; - - this.options = Object.assign({}, options); - }, - /** - * @override - */ - start: function () { - this.$colorpickerArea = this.$('.o_color_pick_area'); - this.$colorpickerPointer = this.$('.o_picker_pointer'); - this.$colorSlider = this.$('.o_color_slider'); - this.$colorSliderPointer = this.$('.o_slider_pointer'); - this.$opacitySlider = this.$('.o_opacity_slider'); - this.$opacitySliderPointer = this.$('.o_opacity_pointer'); - - var defaultColor = this.options.defaultColor || '#FF0000'; - var rgba = ColorpickerWidget.convertCSSColorToRgba(defaultColor); - if (rgba) { - this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); - } - - // Pre-fill the inputs. This is because on safari, the baseline for empty - // input is not the baseline of where the text would be, but the bottom - // of the input itself. (see https://bugs.webkit.org/show_bug.cgi?id=142968) - // This will cause the first _updateUI to alter the layout of the colorpicker - // which will change its height. Changing the height of an element inside of - // the callback to a ResizeObserver observing it will cause an error - // (ResizeObserver loop completed with undelivered notifications) that cannot - // be caught, which will open the crash manager. Prefilling the inputs sets - // the baseline correctly from the start so the layout doesn't change. - Object.entries(this.colorComponents).forEach(([component, value]) => { - const input = this.el.querySelector(`.o_${component}_input`); - if (input) { - input.value = value; - } - }); - const resizeObserver = new window.ResizeObserver(() => { - this._updateUI(); - }); - resizeObserver.observe(this.el); - - // Need to be bound on all documents to work in all possible cases (we - // have to be able to start dragging/moving from the colorpicker to - // anywhere on the screen, crossing iframes). - this.$documents = $([window.top, ...Array.from(window.top.frames).filter(frame => { - try { - const document = frame.document; - return !!document; - } catch { - // We cannot access the document (cross origin). - return false; - } - })].map(w => w.document)); - this.throttleOnMouseMove = throttleForAnimation((ev) => { - this._onMouseMovePicker(ev); - this._onMouseMoveSlider(ev); - this._onMouseMoveOpacitySlider(ev); - }); - this.$documents.on(`mousemove.${this.uniqueId}`, this.throttleOnMouseMove); - this.$documents.on(`mouseup.${this.uniqueId}`, debounce((ev) => { - if (this.pickerFlag || this.sliderFlag || this.opacitySliderFlag) { - this._colorSelected(); - } - this.pickerFlag = false; - this.sliderFlag = false; - this.opacitySliderFlag = false; - }, 10)); - - this.previewActive = true; - return this._super.apply(this, arguments); - }, - /** - * @override - */ - destroy: function () { - this._super.apply(this, arguments); - if (this.throttleOnMouseMove) { - this.$documents.off(`.${this.uniqueId}`); - this.throttleOnMouseMove.cancel(); - } - }, - /** - * Sets the currently selected color - * - * @param {string} color rgb[a] - */ - 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(); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Updates input values, color preview, picker and slider pointer positions. - * - * @private - */ - _updateUI: function () { - var self = this; - - // Update inputs - for (const [color, value] of Object.entries(this.colorComponents)) { - self.$(`.o_${color}_input`).val(value); - } - - // Update preview - this.$('.o_color_preview').css('background-color', this.colorComponents.cssColor); - - // Update picker area and picker pointer position - this.$colorpickerArea.css( - "background-color", - `hsl(${this.colorComponents.hue}, 100%, 50%)` - ); - var top = (100 - this.colorComponents.lightness) * this.$colorpickerArea.height() / 100; - var left = this.colorComponents.saturation * this.$colorpickerArea.width() / 100; - this.$colorpickerPointer.css({ - top: (top - 5) + 'px', - left: (left - 5) + 'px', - }); - - // Update color slider position - var height = this.$colorSlider.height(); - var y = this.colorComponents.hue * height / 360; - this.$colorSliderPointer.css('top', Math.round(y - 2)); - - if (! this.options.noTransparency) { - // Update opacity slider position - var heightOpacity = this.$opacitySlider.height(); - var z = heightOpacity * (1 - this.colorComponents.opacity / 100.0); - this.$opacitySliderPointer.css('top', Math.round(z - 2)); - - // Add gradient color on opacity slider - this.$opacitySlider.css('background', 'linear-gradient(' + this.colorComponents.hex + ' 0%, transparent 100%)'); - } - }, - /** - * Updates colors according to given hex value. Opacity is left unchanged. - * - * @private - * @param {string} hex - hexadecimal code - */ - _updateHex: function (hex) { - var rgb = ColorpickerWidget.convertCSSColorToRgba(hex); - if (!rgb) { - return; - } - Object.assign(this.colorComponents, - {hex: hex}, - rgb, - ColorpickerWidget.convertRgbToHsl(rgb.red, rgb.green, rgb.blue) - ); - this._updateCssColor(); - }, - /** - * Updates colors according to given RGB values. - * - * @private - * @param {integer} r - * @param {integer} g - * @param {integer} b - * @param {integer} [a] - */ - _updateRgba: function (r, g, b, a) { - // Remove full transparency in case some lightness is added - const opacity = a || this.colorComponents.opacity; - if (opacity < 0.1 && (r > 0.1 || g > 0.1 || b > 0.1)) { - a = 100; - } - - // We update the hexadecimal code by transforming into a css color and - // ignoring the opacity (we don't display opacity component in hexa as - // not supported on all browsers) - var hex = ColorpickerWidget.convertRgbaToCSSColor(r, g, b); - if (!hex) { - return; - } - Object.assign(this.colorComponents, - {red: r, green: g, blue: b}, - a === undefined ? {} : {opacity: a}, - {hex: hex}, - ColorpickerWidget.convertRgbToHsl(r, g, b) - ); - this._updateCssColor(); - }, - /** - * Updates colors according to given HSL values. - * - * @private - * @param {integer} h - * @param {integer} s - * @param {integer} l - */ - _updateHsl: function (h, s, l) { - // Remove full transparency in case some lightness is added - let a = this.colorComponents.opacity; - if (a < 0.1 && l > 0.1) { - a = 100; - } - - var rgb = ColorpickerWidget.convertHslToRgb(h, s, l); - if (!rgb) { - return; - } - // We receive an hexa as we ignore the opacity - const hex = ColorpickerWidget.convertRgbaToCSSColor(rgb.red, rgb.green, rgb.blue); - Object.assign(this.colorComponents, - {hue: h, saturation: s, lightness: l}, - rgb, - {hex: hex}, - {opacity: a}, - ); - this._updateCssColor(); - }, - /** - * Updates color opacity. - * - * @private - * @param {integer} a - */ - _updateOpacity: function (a) { - if (a < 0 || a > 100) { - return; - } - Object.assign(this.colorComponents, - {opacity: a} - ); - this._updateCssColor(); - }, - /** - * Trigger an event to annonce that the widget value has changed - * - * @private - */ - _colorSelected: function () { - this.trigger_up('colorpicker_select', this.colorComponents); - }, - /** - * Updates css color representation. - * - * @private - */ - _updateCssColor: function () { - const r = this.colorComponents.red; - const g = this.colorComponents.green; - const b = this.colorComponents.blue; - const a = this.colorComponents.opacity; - Object.assign(this.colorComponents, - {cssColor: ColorpickerWidget.convertRgbaToCSSColor(r, g, b, a)} - ); - if (this.previewActive) { - this.trigger_up('colorpicker_preview', this.colorComponents); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Event} ev - */ - _onKeypress: function (ev) { - if (ev.charCode === $.ui.keyCode.ENTER) { - if (ev.target.tagName === 'INPUT') { - this._onChangeInputs(ev); - } - ev.preventDefault(); - this.trigger_up('enter_key_color_colorpicker'); - } - }, - /** - * @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) { - ev.target.select(); - this.selectedHexValue = ev.target.value; - return; - } - this.selectedHexValue = ''; - }, - /** - * Updates color when the user starts clicking on the picker. - * - * @private - * @param {Event} ev - */ - _onMouseDownPicker: function (ev) { - this.pickerFlag = true; - ev.preventDefault(); - this._onMouseMovePicker(ev); - }, - /** - * Updates saturation and lightness values on mouse drag over picker. - * - * @private - * @param {Event} ev - */ - _onMouseMovePicker: function (ev) { - if (!this.pickerFlag) { - return; - } - - var offset = this.$colorpickerArea.offset(); - var top = ev.pageY - offset.top; - var left = ev.pageX - offset.left; - var saturation = Math.round(100 * left / this.$colorpickerArea.width()); - var lightness = Math.round(100 * (this.$colorpickerArea.height() - top) / this.$colorpickerArea.height()); - saturation = utils.confine(saturation, 0, 100); - lightness = utils.confine(lightness, 0, 100); - - this._updateHsl(this.colorComponents.hue, saturation, lightness); - this._updateUI(); - }, - /** - * Updates color when user starts clicking on slider. - * - * @private - * @param {Event} ev - */ - _onMouseDownSlider: function (ev) { - this.sliderFlag = true; - ev.preventDefault(); - this._onMouseMoveSlider(ev); - }, - /** - * Updates hue value on mouse drag over slider. - * - * @private - * @param {Event} ev - */ - _onMouseMoveSlider: function (ev) { - if (!this.sliderFlag) { - return; - } - - var y = ev.pageY - this.$colorSlider.offset().top; - var hue = Math.round(360 * y / this.$colorSlider.height()); - hue = utils.confine(hue, 0, 360); - - this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness); - this._updateUI(); - }, - /** - * Updates opacity when user starts clicking on opacity slider. - * - * @private - * @param {Event} ev - */ - _onMouseDownOpacitySlider: function (ev) { - this.opacitySliderFlag = true; - ev.preventDefault(); - this._onMouseMoveOpacitySlider(ev); - }, - /** - * Updates opacity value on mouse drag over opacity slider. - * - * @private - * @param {Event} ev - */ - _onMouseMoveOpacitySlider: function (ev) { - if (!this.opacitySliderFlag || this.options.noTransparency) { - return; - } - - var y = ev.pageY - this.$opacitySlider.offset().top; - var opacity = Math.round(100 * (1 - y / this.$opacitySlider.height())); - opacity = utils.confine(opacity, 0, 100); - - this._updateOpacity(opacity); - this._updateUI(); - }, - /** - * Called when input value is changed -> Updates UI: Set picker and slider - * position and set colors. - * - * @private - * @param {Event} ev - */ - _onChangeInputs: function (ev) { - switch ($(ev.target).data('colorMethod')) { - case 'hex': - this._updateHex(this.$('.o_hex_input').val()); - break; - case 'rgb': - this._updateRgba( - parseInt(this.$('.o_red_input').val()), - parseInt(this.$('.o_green_input').val()), - parseInt(this.$('.o_blue_input').val()) - ); - break; - case 'hsl': - this._updateHsl( - parseInt(this.$('.o_hue_input').val()), - parseInt(this.$('.o_saturation_input').val()), - parseInt(this.$('.o_lightness_input').val()) - ); - break; - case 'opacity': - this._updateOpacity(parseInt(this.$('.o_opacity_input').val())); - break; - } - this._updateUI(); - this._colorSelected(); - }, -}); - -//-------------------------------------------------------------------------- -// Static -//-------------------------------------------------------------------------- - -/** - * Converts RGB color components to HSL components. - * - * @static - * @param {integer} r - [0, 255] - * @param {integer} g - [0, 255] - * @param {integer} b - [0, 255] - * @returns {Object|false} - * - hue [0, 360[ (float) - * - saturation [0, 100] (float) - * - lightness [0, 100] (float) - */ -ColorpickerWidget.convertRgbToHsl = function (r, g, b) { - if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 - || typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 - || typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) { - return false; - } - - var red = r / 255; - var green = g / 255; - var blue = b / 255; - var maxColor = Math.max(red, green, blue); - var minColor = Math.min(red, green, blue); - var delta = maxColor - minColor; - var hue = 0; - var saturation = 0; - var lightness = (maxColor + minColor) / 2; - if (delta) { - if (maxColor === red) { - hue = (green - blue) / delta; - } - if (maxColor === green) { - hue = 2 + (blue - red) / delta; - } - if (maxColor === blue) { - hue = 4 + (red - green) / delta; - } - if (maxColor) { - saturation = delta / (1 - Math.abs(2 * lightness - 1)); - } - } - hue = 60 * hue; - return { - hue: hue < 0 ? hue + 360 : hue, - saturation: saturation * 100, - lightness: lightness * 100, - }; -}; -/** - * Converts HSL color components to RGB components. - * - * @static - * @param {number} h - [0, 360[ (float) - * @param {number} s - [0, 100] (float) - * @param {number} l - [0, 100] (float) - * @returns {Object|false} - * - red [0, 255] (integer) - * - green [0, 255] (integer) - * - blue [0, 255] (integer) - */ -ColorpickerWidget.convertHslToRgb = function (h, s, l) { - if (typeof (h) !== 'number' || isNaN(h) || h < 0 || h > 360 - || typeof (s) !== 'number' || isNaN(s) || s < 0 || s > 100 - || typeof (l) !== 'number' || isNaN(l) || l < 0 || l > 100) { - return false; - } - - var huePrime = h / 60; - var saturation = s / 100; - var lightness = l / 100; - var chroma = saturation * (1 - Math.abs(2 * lightness - 1)); - var secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1)); - var lightnessAdjustment = lightness - chroma / 2; - var precision = 255; - chroma = Math.round((chroma + lightnessAdjustment) * precision); - secondComponent = Math.round((secondComponent + lightnessAdjustment) * precision); - lightnessAdjustment = Math.round((lightnessAdjustment) * precision); - if (huePrime >= 0 && huePrime < 1) { - return { - red: chroma, - green: secondComponent, - blue: lightnessAdjustment, - }; - } - if (huePrime >= 1 && huePrime < 2) { - return { - red: secondComponent, - green: chroma, - blue: lightnessAdjustment, - }; - } - if (huePrime >= 2 && huePrime < 3) { - return { - red: lightnessAdjustment, - green: chroma, - blue: secondComponent, - }; - } - if (huePrime >= 3 && huePrime < 4) { - return { - red: lightnessAdjustment, - green: secondComponent, - blue: chroma, - }; - } - if (huePrime >= 4 && huePrime < 5) { - return { - red: secondComponent, - green: lightnessAdjustment, - blue: chroma, - }; - } - if (huePrime >= 5 && huePrime <= 6) { - return { - red: chroma, - green: lightnessAdjustment, - blue: secondComponent, - }; - } - return false; -}; -/** - * Converts RGBA color components to a normalized CSS color: if the opacity - * is invalid or equal to 100, a hex is returned; otherwise a rgba() css color - * is returned. - * - * Those choice have multiple reason: - * - A hex color is more common to c/c from other utilities on the web and is - * also shorter than rgb() css colors - * - Opacity in hexadecimal notations is not supported on all browsers and is - * also less common to use. - * - * @static - * @param {integer} r - [0, 255] - * @param {integer} g - [0, 255] - * @param {integer} b - [0, 255] - * @param {float} a - [0, 100] - * @returns {string} - */ -ColorpickerWidget.convertRgbaToCSSColor = function (r, g, b, a) { - if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 - || typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 - || typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) { - return false; - } - if (typeof (a) !== 'number' || isNaN(a) || a < 0 || Math.abs(a - 100) < Number.EPSILON) { - const rr = r < 16 ? '0' + r.toString(16) : r.toString(16); - const gg = g < 16 ? '0' + g.toString(16) : g.toString(16); - const bb = b < 16 ? '0' + b.toString(16) : b.toString(16); - return (`#${rr}${gg}${bb}`).toUpperCase(); - } - return `rgba(${r}, ${g}, ${b}, ${parseFloat((a / 100.0).toFixed(3))})`; -}; -/** - * Converts a CSS color (rgb(), rgba(), hexadecimal) to RGBA color components. - * - * Note: we don't support using and displaying hexadecimal color with opacity - * but this method allows to receive one and returns the correct opacity value. - * - * @static - * @param {string} cssColor - hexadecimal code or rgb() or rgba() - * @returns {Object|false} - * - red [0, 255] (integer) - * - green [0, 255] (integer) - * - blue [0, 255] (integer) - * - opacity [0, 100.0] (float) - */ -ColorpickerWidget.convertCSSColorToRgba = function (cssColor) { - // Check if cssColor is a rgba() or rgb() color - const rgba = cssColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); - if (rgba) { - if (rgba[4] === undefined) { - rgba[4] = 1; - } - return { - red: parseInt(rgba[1]), - green: parseInt(rgba[2]), - blue: parseInt(rgba[3]), - opacity: Math.round(parseFloat(rgba[4]) * 100), - }; - } - - // Otherwise, check if cssColor is an hexadecimal code color - if (/^#([0-9A-F]{6}|[0-9A-F]{8})$/i.test(cssColor)) { - return { - red: parseInt(cssColor.substr(1, 2), 16), - green: parseInt(cssColor.substr(3, 2), 16), - blue: parseInt(cssColor.substr(5, 2), 16), - opacity: (cssColor.length === 9 ? (parseInt(cssColor.substr(7, 2), 16) / 255) : 1) * 100, - }; - } - - // TODO maybe implement a support for receiving css color like 'red' or - // 'transparent' (which are now considered non-css color by isCSSColor...) - // Note: however, if ever implemented be careful of 'white'/'black' which - // actually are color names for our color system... - - return false; -}; -/** - * Converts a CSS color (rgb(), rgba(), hexadecimal) to a normalized version - * of the same color (@see convertRgbaToCSSColor). - * - * Normalized color can be safely compared using string comparison. - * - * @static - * @param {string} cssColor - hexadecimal code or rgb() or rgba() - * @returns {string} - the normalized css color or the given css color if it - * failed to be normalized - */ -ColorpickerWidget.normalizeCSSColor = function (cssColor) { - const rgba = ColorpickerWidget.convertCSSColorToRgba(cssColor); - if (!rgba) { - return cssColor; - } - return ColorpickerWidget.convertRgbaToCSSColor(rgba.red, rgba.green, rgba.blue, rgba.opacity); -}; -/** - * Checks if a given string is a css color. - * - * @static - * @param {string} cssColor - * @returns {boolean} - */ -ColorpickerWidget.isCSSColor = function (cssColor) { - return ColorpickerWidget.convertCSSColorToRgba(cssColor) !== false; -}; - -/** - * Mixes two colors by applying a weighted average of their red, green and blue - * components. - * - * @static - * @param {string} cssColor1 - hexadecimal code or rgb() or rgba() - * @param {string} cssColor2 - hexadecimal code or rgb() or rgba() - * @param {number} weight - a number between 0 and 1 - * @returns {string} - mixed color in hexadecimal format - */ -ColorpickerWidget.mixCssColors = function (cssColor1, cssColor2, weight) { - const rgba1 = ColorpickerWidget.convertCSSColorToRgba(cssColor1); - const rgba2 = ColorpickerWidget.convertCSSColorToRgba(cssColor2); - const rgb1 = [rgba1.red, rgba1.green, rgba1.blue]; - const rgb2 = [rgba2.red, rgba2.green, rgba2.blue]; - const [r, g, b] = rgb1.map((_, idx) => Math.round(rgb2[idx] + (rgb1[idx] - rgb2[idx]) * weight)); - return ColorpickerWidget.convertRgbaToCSSColor(r, g, b); -}; - -const ColorpickerDialog = Dialog.extend({ - /** - * @override - */ - init: function (parent, options) { - this.options = options || {}; - this._super(parent, Object.assign({ - size: 'small', - title: _t('Pick a color'), - buttons: [ - {text: _t('Choose'), classes: 'btn-primary', close: true, click: this._onFinalPick.bind(this)}, - {text: _t('Discard'), close: true}, - ], - }, this.options)); - }, - /** - * @override - */ - start: function () { - const proms = [this._super(...arguments)]; - this.colorPicker = new ColorpickerWidget(this, Object.assign({ - colorPreview: true, - }, this.options)); - proms.push(this.colorPicker.appendTo(this.$el)); - return Promise.all(proms); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onFinalPick: function () { - this.trigger_up('colorpicker:saved', this.colorPicker.colorComponents); - }, -}); - -export default { - ColorpickerDialog: ColorpickerDialog, - ColorpickerWidget: ColorpickerWidget, -}; diff --git a/addons/web/static/src/legacy/js/widgets/colorpicker_dialog.js b/addons/web/static/src/legacy/js/widgets/colorpicker_dialog.js new file mode 100644 index 00000000000..ddffd55debc --- /dev/null +++ b/addons/web/static/src/legacy/js/widgets/colorpicker_dialog.js @@ -0,0 +1,47 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { ComponentWrapper } from "web.OwlCompatibility"; + +import Dialog from "web.Dialog"; +import { Colorpicker } from '@web/core/colorpicker/colorpicker'; + +export const ColorpickerDialog = Dialog.extend({ + /** + * @override + */ + init: function (parent, options) { + this.options = options || {}; + this._super(parent, Object.assign({ + size: 'small', + title: _t('Pick a color'), + buttons: [ + {text: _t('Choose'), classes: 'btn-primary', close: true, click: this._onFinalPick.bind(this)}, + {text: _t('Discard'), close: true}, + ], + }, this.options)); + }, + /** + * @override + */ + start: function () { + const proms = [this._super(...arguments)]; + this.colorpicker = new ComponentWrapper(this, Colorpicker, { + colorPreview: true, + }); + this._colorpickerComponent = this.colorpicker.node.component; + proms.push(this.colorpicker.mount(this.$el[0])); + return Promise.all(proms); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onFinalPick: function () { + this.trigger_up('colorpicker:saved', this._colorpickerComponent.colorComponents); + }, +}); diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index 885bcadf425..0b497d6f32f 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -22,6 +22,23 @@ Odoo Web Editor widget. # MAIN BUNDLES #---------------------------------------------------------------------- + 'web_editor.assets_legacy_wysiwyg': [ + 'web_editor/static/src/js/editor/snippets.editor.js', + 'web_editor/static/src/js/editor/snippets.options.js', + ], + 'web_editor.wysiwyg_iframe_editor_assets': [ + ('include', 'web.assets_common'), + ('include', 'web_editor.assets_wysiwyg'), + ('include', 'web_editor.assets_legacy_wysiwyg'), + ], + 'web_editor.assets_media_dialog': [ + 'web_editor/static/src/components/**/*', + ], + 'web_editor.assets_tests_styles': [ + ('include', 'web._assets_helpers'), + 'web_editor/static/src/js/editor/odoo-editor/src/base_style.scss', + 'web_editor/static/src/js/editor/odoo-editor/src/checklist.scss', + ], 'web_editor.assets_wysiwyg': [ # lib 'web_editor/static/lib/cropperjs/cropper.css', @@ -53,6 +70,8 @@ Odoo Web Editor widget. # utils 'web_editor/static/src/js/wysiwyg/linkDialogCommand.js', 'web_editor/static/src/js/wysiwyg/PeerToPeer.js', + 'web_editor/static/src/js/wysiwyg/conflict_dialog.js', + 'web_editor/static/src/js/wysiwyg/conflict_dialog.xml', # odoo utils ('include', 'web._assets_helpers'), @@ -73,13 +92,11 @@ Odoo Web Editor widget. # widgets & plugins 'web_editor/static/src/js/wysiwyg/widgets/**/*', - 'web_editor/static/src/js/editor/snippets.editor.js', 'web_editor/static/src/js/editor/toolbar.js', - 'web_editor/static/src/js/editor/snippets.options.js', # Launcher + 'web_editor/static/src/js/wysiwyg/wysiwyg_jquery_extention.js', 'web_editor/static/src/js/wysiwyg/wysiwyg.js', - 'web_editor/static/src/js/wysiwyg/widget.js', 'web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js', 'web_editor/static/src/xml/editor.xml', @@ -89,14 +106,6 @@ Odoo Web Editor widget. 'web_editor/static/src/xml/wysiwyg.xml', 'web_editor/static/src/xml/wysiwyg_colorpicker.xml', ], - 'web_editor.assets_media_dialog': [ - 'web_editor/static/src/components/**/*', - ], - 'web_editor.assets_tests_styles': [ - ('include', 'web._assets_helpers'), - 'web_editor/static/src/js/editor/odoo-editor/src/base_style.scss', - 'web_editor/static/src/js/editor/odoo-editor/src/checklist.scss', - ], 'web.assets_common': [ 'web_editor/static/src/js/editor/odoo-editor/src/base_style.scss', 'web_editor/static/lib/vkbeautify/**/*', @@ -116,10 +125,11 @@ Odoo Web Editor widget. 'web_editor/static/src/scss/web_editor.common.scss', 'web_editor/static/src/scss/web_editor.backend.scss', - 'web_editor/static/src/js/wysiwyg/dialog.js', 'web_editor/static/src/js/frontend/loader.js', 'web_editor/static/src/js/backend/**/*', 'web_editor/static/src/xml/backend.xml', + + ('include', 'web_editor.assets_wysiwyg'), ], "web.dark_mode_assets_backend": [ 'web_editor/static/src/scss/odoo-editor/powerbox.dark.scss', @@ -144,8 +154,8 @@ Odoo Web Editor widget. 'web_editor/static/src/scss/web_editor.common.scss', 'web_editor/static/src/scss/web_editor.frontend.scss', - 'web_editor/static/src/js/wysiwyg/dialog.js', 'web_editor/static/src/js/frontend/loader.js', + 'web_editor/static/src/js/frontend/loadWysiwygFromTextarea.js', ], #---------------------------------------------------------------------- @@ -172,7 +182,7 @@ Odoo Web Editor widget. # ---------------------------------------------------------------------- 'web.qunit_suite_tests': [ - ('include', 'web_editor.assets_wysiwyg'), + ('include', 'web_editor.assets_legacy_wysiwyg'), 'web_editor/static/tests/**/*', 'web_editor/static/src/js/editor/odoo-editor/test/utils.js' diff --git a/addons/web_editor/static/src/components/emoji_popover/emoji_popover.js b/addons/web_editor/static/src/components/emoji_popover/emoji_popover.js deleted file mode 100644 index 76555dc01c6..00000000000 --- a/addons/web_editor/static/src/components/emoji_popover/emoji_popover.js +++ /dev/null @@ -1,19 +0,0 @@ -/** @odoo-module */ - -import { useWowlService } from '@web/legacy/utils'; -import { Component, onRendered, xml } from "@odoo/owl"; -import { EmojiPicker } from '@web/core/emoji_picker/emoji_picker'; - -export class EmojiPickerWrapper extends Component { - setup() { - this.popover = useWowlService('popover'); - - onRendered(() => { - this.popover.add(this.props.targetEl, EmojiPicker, { - onSelect: this.props.onSelect, - }, { position: `bottom-${this.props.position}`}); - }); - } -} - -EmojiPickerWrapper.template = xml``; diff --git a/addons/web_editor/static/src/components/media_dialog/media_dialog.js b/addons/web_editor/static/src/components/media_dialog/media_dialog.js index 06150f1e780..9ca7649ad78 100644 --- a/addons/web_editor/static/src/components/media_dialog/media_dialog.js +++ b/addons/web_editor/static/src/components/media_dialog/media_dialog.js @@ -1,7 +1,6 @@ /** @odoo-module **/ import { useService } from '@web/core/utils/hooks'; -import { useWowlService } from '@web/legacy/utils'; import { Dialog } from '@web/core/dialog/dialog'; import { Notebook } from '@web/core/notebook/notebook'; import { ImageSelector } from './image_selector'; @@ -9,7 +8,7 @@ import { DocumentSelector } from './document_selector'; import { IconSelector } from './icon_selector'; import { VideoSelector } from './video_selector'; -import { Component, useState, onRendered, xml } from "@odoo/owl"; +import { Component, useState } from "@odoo/owl"; export const TABS = { IMAGES: { @@ -236,16 +235,3 @@ MediaDialog.components = { Dialog, Notebook, }; - -export class MediaDialogWrapper extends Component { - setup() { - this.dialogs = useWowlService('dialog'); - - onRendered(() => { - this.dialogs.add(MediaDialog, this.props, { - onClose: this.props.close, - }); - }); - } -} -MediaDialogWrapper.template = xml``; diff --git a/addons/web_editor/static/src/components/media_dialog/media_dialog_wrapper.js b/addons/web_editor/static/src/components/media_dialog/media_dialog_wrapper.js new file mode 100644 index 00000000000..319718babd5 --- /dev/null +++ b/addons/web_editor/static/src/components/media_dialog/media_dialog_wrapper.js @@ -0,0 +1,19 @@ + +/** @odoo-module **/ + +import { useWowlService } from '@web/legacy/utils'; +import { Component, onRendered, xml } from "@odoo/owl"; +import { MediaDialog } from "./media_dialog"; + +export class MediaDialogWrapper extends Component { + setup() { + this.dialogs = useWowlService('dialog'); + + onRendered(() => { + this.dialogs.add(MediaDialog, this.props, { + onClose: this.props.close, + }); + }); + } +} +MediaDialogWrapper.template = xml``; diff --git a/addons/web_editor/static/src/components/media_dialog/video_selector.js b/addons/web_editor/static/src/components/media_dialog/video_selector.js index e6088583b79..89e96dfb594 100644 --- a/addons/web_editor/static/src/components/media_dialog/video_selector.js +++ b/addons/web_editor/static/src/components/media_dialog/video_selector.js @@ -2,7 +2,6 @@ import { useService } from '@web/core/utils/hooks'; import { debounce } from '@web/core/utils/timing'; -import { qweb } from 'web.core'; import { Component, useState, useRef, onMounted, onWillStart } from "@odoo/owl"; @@ -195,9 +194,15 @@ export class VideoSelector extends Component { */ static createElements(selectedMedia) { return selectedMedia.map(video => { - const template = document.createElement('template'); - template.innerHTML = qweb.render('web_editor.videoWrapper', { src: video.src }); - return template.content.firstChild; + const div = document.createElement('div'); + div.dataset.oeExpression = video.src; + div.innerHTML = ` +
+
+ + `; + div.querySelector('iframe').src = video.src; + return div; }); } } diff --git a/addons/web_editor/static/src/components/media_dialog/video_selector.xml b/addons/web_editor/static/src/components/media_dialog/video_selector.xml index ece78946bff..b0a2424a4a5 100644 --- a/addons/web_editor/static/src/components/media_dialog/video_selector.xml +++ b/addons/web_editor/static/src/components/media_dialog/video_selector.xml @@ -1,10 +1,5 @@ -
-
-
-