[IMP] web_editor,*: convert wysiwyg to owl

The goal of this PR is to remove all dependency to legacy assets in the wysiwyg because the wysiwyg is loaded on a lot of form view through the html_field, thus defeating the purpose of the legacy assets being lazy loaded.

To remove the dependencies, all the legacy widget that the wysiwyg uses had to
be converted to owl. In order to finish the PR faster, only a partial conversion
of the widgets is done, changing only the part of the code that was necessary
for it to work instead of rewriting the whole widget from scratch. Another pass
should be done to convert all those widgets to fully embrace the owl paradigm.

task-3175256

closes odoo/odoo#118966

Related: odoo/upgrade#4750
Related: odoo/enterprise#42141
Signed-off-by: Geelen Sébastien (sge) <sge@odoo.com>
This commit is contained in:
Odoo's Mergebot
2023-07-19 11:39:45 +02:00
committed by GitHub
97 changed files with 4855 additions and 4525 deletions
+18 -6
View File
@@ -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/**/*',
@@ -14,7 +14,7 @@
<meta name="viewport" content="width=device-width; initial-scale=1.0; maximum-scale=1.0; user-scalable=no;"/>
<meta http-equiv="X-UA-Compatible" content="IE=9; IE=8; IE=7; IE=EDGE" />
<t t-call="mass_mailing.mass_mailing_mail_style"/>
<t t-out="mailing_style"/>
<!--
Prevent Outlook from distorting images with DPI scaling (see
@@ -34,75 +34,6 @@
</body>
</html>
</template>
<template id="mass_mailing_mail_style">
<style>
.o_layout * {
box-sizing: border-box !important;
}
.o_layout :not(.fa) {
font-family: Arial,Helvetica Neue,Helvetica,sans-serif;
}
/* Remove space around the email design. */
html,
body {
margin: 0 auto !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
}
/* Stop Outlook resizing small text. */
* {
-ms-text-size-adjust: 100%;
}
/* Stop Outlook from adding extra spacing to tables. */
table,
td {
mso-table-lspace: 0pt !important;
mso-table-rspace: 0pt !important;
}
/* Use a better rendering method when resizing images in Outlook IE. */
img {
-ms-interpolation-mode:bicubic;
}
/* Prevent Windows 10 Mail from underlining links. Styles for underlined links should be inline. */
a {
text-decoration: none;
}
@media screen and (max-width: 768px) {
.o_mail_snippet_general .container {
width: 100% !important;
}
.s_header_social table td, .s_header_text_social table td, .s_header_logo table td,
.o_mail_block_footer_social td,
.s_showcase td,
.s_mail_product_list td,
.s_references td {
text-align: center !important;
}
}
@media screen and (max-width: 1135px) {
.o_stacking_wrapper {
width: 100% !important;
height: unset !important;
}
td {
max-width: inherit !important;
}
img:only-child:not(.img-fluid) {
object-fit: cover;
min-width: 100% !important;
}
.o_desktop_h100 {
height: unset !important;
}
}
</style>
</template>
</data>
<data noupdate="0">
@@ -10,10 +10,10 @@ import { qweb } from 'web.core';
import { useService } from "@web/core/utils/hooks";
import { buildQuery } from "web.rpc";
import { HtmlField, htmlField } from "@web_editor/js/backend/html_field";
import { getWysiwygClass } from 'web_editor.loader';
import { device } from 'web.config';
import { MassMailingMobilePreviewDialog } from "./mass_mailing_mobile_preview";
import { getRangePosition } from '@web_editor/js/editor/odoo-editor/src/utils/utils';
import { MassMailingWysiwyg } from '@mass_mailing/js/mass_mailing_wysiwyg';
const {
onWillStart,
@@ -23,6 +23,18 @@ const {
} = owl;
export class MassMailingHtmlField extends HtmlField {
static props = {
...standardFieldProps,
...HtmlField.props,
filterTemplates: { type: Boolean, optional: true },
inlineField: { type: String, optional: true },
iframeHtmlClass: { type: String, optional: true },
}
static components = {
...HtmlField.components,
Wysiwyg: MassMailingWysiwyg,
}
setup() {
super.setup();
@@ -49,8 +61,14 @@ export class MassMailingHtmlField extends HtmlField {
onIframeUpdated: () => this.onIframeUpdated(),
snippets: 'mass_mailing.email_designer_snippets',
resizable: false,
defaultDataForLinkTools: { isNewWindow: true },
toolbarTemplate: 'mass_mailing.web_editor_toolbar',
linkOptions: {
...super.wysiwygOptions.linkOptions,
initialIsNewWindow: true,
},
toolbarOptions: {
...super.wysiwygOptions.toolbarOptions,
dropDirection: 'dropup',
},
onWysiwygBlur: () => {
this.commitChanges();
this.wysiwyg.odooEditor.toolbarHide();
@@ -139,7 +157,7 @@ export class MassMailingHtmlField extends HtmlField {
this.wysiwyg.odooEditor.historyRevertCurrentStep();
const fieldName = this.props.inlineField;
await this.props.record.update({[fieldName]: this._unWrap(inlineHtml)});
await this.props.record.update({[fieldName]: inlineHtml});
})();
return this._pendingCommitChanges;
}
@@ -180,6 +198,7 @@ export class MassMailingHtmlField extends HtmlField {
}
async _onSnippetsLoaded() {
if (status(this) === 'destroyed') return;
if (this.wysiwyg.snippetsMenu && $(window.top.document).find('.o_mass_mailing_form_full_width')[0]) {
// In full width form mode, ensure the snippets menu's scrollable is
// in the form view, not in the iframe.
@@ -206,6 +225,7 @@ export class MassMailingHtmlField extends HtmlField {
})
// Templates taken from old mailings
const result = await this.rpc(rpcQuery.route, rpcQuery.params);
if (status(this) === 'destroyed') return;
const templatesParams = result.map(values => {
return {
id: values.id,
@@ -589,7 +609,7 @@ export class MassMailingHtmlField extends HtmlField {
this.wysiwyg.$editable[0].focus();
}
initializeDesignTabCss(this.wysiwyg.$editable);
this.wysiwyg.trigger('reload_snippet_dropzones');
this.wysiwyg.snippetsMenu.reload_snippet_dropzones();
this.onIframeUpdated();
this.wysiwyg.odooEditor.historyStep(true);
// The value of the field gets updated upon editor blur. If for any
@@ -599,9 +619,6 @@ export class MassMailingHtmlField extends HtmlField {
await this.commitChanges();
this._switchingTheme = false;
}
async _getWysiwygClass() {
return getWysiwygClass({moduleName: 'mass_mailing.wysiwyg'});
}
/**
* @override
*/
@@ -613,14 +630,6 @@ export class MassMailingHtmlField extends HtmlField {
}
}
MassMailingHtmlField.props = {
...standardFieldProps,
...HtmlField.props,
filterTemplates: { type: Boolean, optional: true },
inlineField: { type: String, optional: true },
iframeHtmlClass: { type: String, optional: true },
};
export const massMailingHtmlField = {
...htmlField,
component: MassMailingHtmlField,
@@ -1,72 +0,0 @@
/** @odoo-module alias=mass_mailing.fix.LinkDialog **/
import { pick } from "@web/core/utils/objects";
import LinkDialog from "wysiwyg.widgets.LinkDialog";
/**
* Primary and link buttons are "hacked" by mailing themes scss. We thus
* have to fix their preview if possible.
*/
LinkDialog.include({
/**
* @override
*/
start() {
const ret = this._super(...arguments);
if (!$(this.editable).find('.o_mail_wrapper').length) {
return ret;
}
this.opened().then(() => {
// Ugly hack to show the real color for link and primary which
// depend on the mailing themes. Note: the hack is not enough as
// the mailing theme changes those colors in some environment,
// sometimes (for example 'btn-primary in this snippet looks like
// that')... we'll consider this a limitation until a master
// refactoring of those mailing themes.
this.__realMMColors = {};
const $previewArea = $('<div/>').addClass('o_mail_snippet_general');
$(this.editable).find('.o_layout').append($previewArea);
['link', 'primary', 'secondary'].forEach(type => {
const $el = $('<a href="#" class="btn btn-' + type + '"/>');
$el.appendTo($previewArea);
this.__realMMColors[type] = {
'border-color': $el.css('border-top-color'),
'background-color': $el.css('background-color'),
'color': $el.css('color'),
};
$el.remove();
this.$('label > .o_btn_preview.btn-' + type)
.css(pick(this.__realMMColors[type], 'background-color', 'color'));
});
$previewArea.remove();
this._adaptPreview();
});
return ret;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_adaptPreview() {
this._super(...arguments);
if (this.__realMMColors) {
var $preview = this.$("#link-preview");
$preview.css('border-color', '');
$preview.css('background-color', '');
$preview.css('color', '');
['link', 'primary', 'secondary'].forEach(type => {
if ($preview.hasClass('btn-' + type) || type === 'link' && !$preview.hasClass('btn')) {
$preview.css(this.__realMMColors[type]);
}
});
}
},
});
@@ -1,7 +1,6 @@
/** @odoo-module alias=mass_mailing.snippets.options **/
import options from "web_editor.snippets.options";
import {ColorpickerWidget} from "web.Colorpicker";
const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select'];
import weUtils from "web_editor.utils";
import {
@@ -10,6 +9,7 @@ import {
getFontName, normalizeFontFamily, initializeDesignTabCss,
transformFontFamilySelector,
} from "mass_mailing.design_constants";
import { isCSSColor, normalizeCSSColor } from "@web/core/utils/colors";
//--------------------------------------------------------------------------
@@ -38,7 +38,7 @@ options.registry.MassMailingImageTools = options.registry.ImageTools.extend({
* @override
*/
_getCSSColorValue(color) {
if (!color || ColorpickerWidget.isCSSColor(color)) {
if (!color || isCSSColor(color)) {
return color;
}
const doc = this.options.document;
@@ -46,7 +46,7 @@ options.registry.MassMailingImageTools = options.registry.ImageTools.extend({
tempEl.className = `bg-${color}`;
const colorValue = window.getComputedStyle(tempEl).getPropertyValue("background-color").trim();
tempEl.parentNode.removeChild(tempEl);
return ColorpickerWidget.normalizeCSSColor(colorValue).replace(/"/g, "'");
return normalizeCSSColor(colorValue).replace(/"/g, "'");
},
});
@@ -1,29 +1,31 @@
/** @odoo-module alias=mass_mailing.wysiwyg **/
/** @odoo-module **/
import Wysiwyg from "web_editor.wysiwyg";
import MassMailingSnippetsMenu from "mass_mailing.snippets.editor";
import {closestElement} from "@web_editor/js/editor/odoo-editor/src/OdooEditor";
import Toolbar from "web_editor.toolbar";
import { ComponentWrapper } from "web.OwlCompatibility";
import { Wysiwyg } from "@web_editor/js/wysiwyg/wysiwyg";
import { closestElement } from "@web_editor/js/editor/odoo-editor/src/OdooEditor";
import { Toolbar } from "@web_editor/js/editor/toolbar";
import { requireWysiwygLegacyModule } from "@web_editor/js/frontend/loader";
import "@web_editor/js/wysiwyg/wysiwyg_iframe";
const MassMailingWysiwyg = Wysiwyg.extend({
export class MassMailingWysiwyg extends Wysiwyg {
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
startEdition: async function () {
const res = await this._super(...arguments);
async startEdition() {
const res = await super.startEdition(...arguments);
// Prevent selection change outside of snippets.
this.$editable.on('mousedown', e => {
if ($(e.target).is('.o_editable:empty') || e.target.querySelector('.o_editable')) {
e.preventDefault();
}
});
this.snippetsMenuToolbar = this.toolbar;
this.snippetsMenuToolbarEl = this.toolbarEl;
return res;
},
}
toggleLinkTools(options = {}) {
this._super({
super.toggleLinkTools({
...options,
// Always open the dialog when the sidebar is folded.
forceDialog: options.forceDialog || this.snippetsMenu.folded
@@ -32,7 +34,7 @@ const MassMailingWysiwyg = Wysiwyg.extend({
// Hide toolbar and avoid it being re-displayed after getDeepRange.
this.odooEditor.document.getSelection().collapseToEnd();
}
},
}
/**
* Sets SnippetsMenu fold state and switches toolbar.
@@ -40,51 +42,59 @@ const MassMailingWysiwyg = Wysiwyg.extend({
*
* @param {Boolean} fold
*/
setSnippetsMenuFolded: async function (fold = true) {
async setSnippetsMenuFolded(fold = true) {
if (fold) {
this.snippetsMenu.setFolded(true);
if (!this.floatingToolbar) {
// Instantiate and configure new toolbar.
this.floatingToolbar = new Toolbar(this, 'web_editor.toolbar');
this.toolbar = this.floatingToolbar;
await this.toolbar.appendTo(document.createElement('void'));
const toolbarWrapper = new ComponentWrapper({}, Toolbar, this.state.toolbarProps);
// The wysiwyg can be instanciated inside an iframe. The dialog
// component is mounted on the global document.
const toolbarWrapperElement = document.createElement('div');
toolbarWrapperElement.style.display = 'contents';
document.body.append(toolbarWrapperElement);
await toolbarWrapper.mount(toolbarWrapperElement);
this.toolbarEl = toolbarWrapperElement.firstChild;
this.floatingToolbarEl = this.toolbarEl;
this._configureToolbar({ snippets: false });
this._updateEditorUI();
this.setCSSVariables(this.toolbar.el);
this.odooEditor.setupToolbar(this.toolbar.el);
this.setCSSVariables(this.toolbarEl);
this.odooEditor.setupToolbar(this.toolbarEl);
if (this.odooEditor.isMobile) {
document.body.querySelector('.o_mail_body').prepend(this.toolbar.el);
document.body.querySelector('.o_mail_body').prepend(this.toolbarEl);
} else {
document.body.append(this.toolbar.el);
document.body.append(this.toolbarEl);
}
} else {
this.toolbar = this.floatingToolbar;
this.toolbarEl = this.floatingToolbarEl;
}
this.toolbar.el.classList.remove('d-none');
this.toolbarEl.classList.remove('d-none');
this.odooEditor.autohideToolbar = true;
this.odooEditor.toolbarHide();
} else {
this.snippetsMenu.setFolded(false);
this.toolbar = this.snippetsMenuToolbar;
this.toolbarEl = this.snippetsMenuToolbarEl;
this.odooEditor.autohideToolbar = false;
if (this.floatingToolbar) {
this.floatingToolbar.el.classList.add('d-none');
if (this.floatingToolbarEl) {
this.floatingToolbarEl.classList.add('d-none');
}
}
this.odooEditor.toolbar = this.toolbar.el;
},
}
/**
* @override
*/
openMediaDialog: function() {
this._super(...arguments);
openMediaDialog() {
super.openMediaDialog(...arguments);
// Opening the dialog in the outer document does not trigger the selectionChange
// (that would normally hide the toolbar) in the iframe.
if (this.snippetsMenu.folded) {
this.odooEditor.toolbarHide();
}
},
}
//--------------------------------------------------------------------------
// Private
@@ -93,17 +103,18 @@ const MassMailingWysiwyg = Wysiwyg.extend({
/**
* @override
*/
_createSnippetsMenuInstance: function (options={}) {
async _createSnippetsMenuInstance(options={}) {
const { MassMailingSnippetsMenu } = await requireWysiwygLegacyModule('@mass_mailing/js/snippets.editor');
return new MassMailingSnippetsMenu(this, Object.assign({
wysiwyg: this,
selectorEditableArea: '.o_editable',
}, options));
},
}
/**
* @override
*/
_getPowerboxOptions: function () {
const options = this._super();
_getPowerboxOptions() {
const options = super._getPowerboxOptions();
const {commands} = options;
const linkCommands = commands.filter(command => command.name === 'Link' || command.name === 'Button');
for (const linkCommand of linkCommands) {
@@ -120,21 +131,21 @@ const MassMailingWysiwyg = Wysiwyg.extend({
}
}
return {...options, commands};
},
}
/**
* @override
*/
_updateEditorUI: function (e) {
this._super(...arguments);
_updateEditorUI(e) {
super._updateEditorUI(...arguments);
// Hide the create-link button if the selection is within a
// background-image.
const selection = this.odooEditor.document.getSelection();
if (!selection) return;
const range = selection.rangeCount && selection.getRangeAt(0);
const isWithinBackgroundImage = !!range && !!closestElement(range.startContainer, '[style*=background-image]');
if (isWithinBackgroundImage) {
this.toolbar.$el.find('#create-link').toggleClass('d-none', true);
this.toolbarEl.querySelector('#create-link').classList.toggle('d-none', true);
}
},
});
}
}
export default MassMailingWysiwyg;
@@ -1,9 +1,9 @@
/** @odoo-module alias=mass_mailing.snippets.editor **/
/** @odoo-module **/
import {_lt} from "web.core";
import snippetsEditor from "web_editor.snippet.editor";
const MassMailingSnippetsMenu = snippetsEditor.SnippetsMenu.extend({
export const MassMailingSnippetsMenu = snippetsEditor.SnippetsMenu.extend({
events: Object.assign({}, snippetsEditor.SnippetsMenu.prototype.events, {
'click .o_we_customize_design_btn': '_onDesignTabClick',
}),
@@ -162,4 +162,3 @@ const MassMailingSnippetsMenu = snippetsEditor.SnippetsMenu.extend({
},
});
export default MassMailingSnippetsMenu;
@@ -0,0 +1,65 @@
.o_layout * {
box-sizing: border-box !important;
}
.o_layout :not(.fa) {
font-family: Arial,Helvetica Neue,Helvetica,sans-serif;
}
/* Remove space around the email design. */
html,
body {
margin: 0 auto !important;
padding: 0 !important;
height: 100% !important;
width: 100% !important;
}
/* Stop Outlook resizing small text. */
* {
-ms-text-size-adjust: 100%;
}
/* Stop Outlook from adding extra spacing to tables. */
table,
td {
mso-table-lspace: 0pt !important;
mso-table-rspace: 0pt !important;
}
/* Use a better rendering method when resizing images in Outlook IE. */
img {
-ms-interpolation-mode:bicubic;
}
/* Prevent Windows 10 Mail from underlining links. Styles for underlined links should be inline. */
a {
text-decoration: none;
}
@media screen and (max-width: 768px) {
.o_mail_snippet_general .container {
width: 100% !important;
}
.s_header_social table td, .s_header_text_social table td, .s_header_logo table td,
.o_mail_block_footer_social td,
.s_showcase td,
.s_mail_product_list td,
.s_references td {
text-align: center !important;
}
}
@media screen and (max-width: 1135px) {
.o_stacking_wrapper {
width: 100% !important;
height: unset !important;
}
td {
max-width: inherit !important;
}
img:only-child:not(.img-fluid) {
object-fit: cover;
min-width: 100% !important;
}
.o_desktop_h100 {
height: unset !important;
}
}
@@ -1,7 +1,7 @@
/** @odoo-module alias=mass_mailing.s_rating_options **/
import { ComponentWrapper } from "web.OwlCompatibility";
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog";
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog_wrapper";
import options from "web_editor.snippets.options";
options.registry.Rating = options.Class.extend({
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="mass_mailing.web_editor_toolbar" t-extend="web_editor.toolbar">
<t t-jquery="div.btn-group.dropdown" t-operation="attributes">
<attribute name="class" value="btn-group dropup"/>
</t>
</t>
</templates>
@@ -1,6 +1,5 @@
/** @odoo-module alias=mass_mailing.field_html_tests **/
import * as ajax from "web.ajax";
import weTestUtils from "web_editor.test_utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import {
@@ -12,6 +11,7 @@ import {
} from "@web/../tests/helpers/utils";
import * as legacyTestUtils from "web.test_utils";
import { assets } from "@web/core/assets";
import { Wysiwyg } from '@web_editor/js/wysiwyg/wysiwyg';
let serverData;
let fixture;
@@ -47,32 +47,22 @@ QUnit.module('field html', (hooks) => {
serverData = { models };
setupViewRegistries();
legacyTestUtils.mock.patch(ajax, {
loadAsset: function (xmlId) {
if (xmlId === 'template.assets') {
return Promise.resolve({
cssLibs: [],
cssContents: ['.field_body {background-color: red;}'],
jsContents: ['window.odoo = {define: function(){}}; // inline asset'],
});
}
if (xmlId === 'template.assets_all_style') {
return Promise.resolve({
cssLibs: $('link[href]:not([type="image/x-icon"])').map(function () {
return $(this).attr('href');
}).get(),
cssContents: ['.field_body {background-color: red;}']
});
}
if (xmlId === 'web_editor.wysiwyg_iframe_editor_assets') {
return Promise.resolve({});
}
throw 'Wrong template';
legacyTestUtils.mock.patch(Wysiwyg.prototype, {
async _getColorpickerTemplate() {
return weTestUtils.COLOR_PICKER_TEMPLATE;
},
async _getAssets() {
return [{
cssLibs: [],
jsLibs: [],
cssContents: ['.field_body {background-color: red;}'],
jsContents: ['window.odoo = {define: function(){}}; // inline asset'],
}];
},
});
});
hooks.afterEach(() => {
legacyTestUtils.mock.unpatch(ajax);
legacyTestUtils.mock.unpatch(Wysiwyg.prototype);
});
QUnit.test('save arch and html', async function (assert) {
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="iframe_css_assets_edit" groups="base.group_user">
<t t-call-assets="mass_mailing.assets_mail_themes" t-js="false"/>
<t t-call="mass_mailing.mass_mailing_mail_style"/>
<t t-call-assets="mass_mailing.assets_mail_themes_edition" t-js="false"/>
<!-- To view the body_arch field in readonly and have it display exactly
like in edit, load all the same css. TODO: move all this and the above
to a separate asset -->
<t t-call-assets="web.assets_frontend" t-js="false"/>
<t t-call-assets="web_editor.assets_wysiwyg" t-js="false"/>
</template>
<template id="iframe_css_assets_readonly" groups="base.group_user">
<link rel="stylesheet" type="text/scss" href="/mass_mailing/static/src/css/basic_theme_readonly.css"/>
<t t-call="mass_mailing.mass_mailing_mail_style"/>
</template>
</odoo>
@@ -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'<style>{styles}</style>')},
minimal_qcontext=True,
raise_if_not_found=False
)
@@ -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'<style>{styles}</style>'),
}, minimal_qcontext=True),
'is_notification': True,
'mailing_id': mailing.id,
'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids],
-1
View File
@@ -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',
@@ -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);
+2 -5
View File
@@ -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
@@ -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();
}
}
@@ -1,20 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="Colorpicker">
<div class="o_colorpicker_widget">
<t t-name="web.Colorpicker" owl="1">
<div class="o_colorpicker_widget" t-ref="el">
<div class="d-flex justify-content-between align-items-stretch mb-2">
<div class="o_color_pick_area position-relative w-75" t-att-style="widget.options.noTransparency ? 'width: 89%;' : None">
<div class="o_color_pick_area position-relative w-75" t-att-style="props.noTransparency ? 'width: 89%;' : None">
<div class="o_picker_pointer rounded-circle p-1 position-absolute" tabindex="-1"/>
</div>
<div class="o_color_slider position-relative">
<div class="o_slider_pointer" tabindex="-1"/>
</div>
<div class="o_opacity_slider position-relative" t-if="!widget.options.noTransparency">
<div class="o_opacity_slider position-relative" t-if="!props.noTransparency">
<div class="o_opacity_pointer" tabindex="-1"/>
</div>
</div>
<div t-if="widget.options.colorPreview" class="o_color_preview mb-1 w-100 p-2"/>
<div t-if="props.colorPreview" class="o_color_preview mb-1 w-100 p-2"/>
<div class="o_color_picker_inputs d-flex justify-content-between mb-2">
<t t-set="input_classes" t-value="'p-0 border-0 text-center font-monospace bg-transparent'" />
@@ -26,7 +26,7 @@
<input type="text" t-attf-class="o_red_input {{input_classes}}" data-color-method="rgb" size="1"/>
<input type="text" t-attf-class="o_green_input {{input_classes}}" data-color-method="rgb" size="1"/>
<input type="text" t-attf-class="o_blue_input me-0 {{input_classes}}" data-color-method="rgb" size="1"/>
<t t-if="!widget.options.noTransparency">
<t t-if="!props.noTransparency">
<input type="text" t-attf-class="o_opacity_input {{input_classes}}" data-color-method="opacity" size="1"/>
<label class="flex-grow-0 ms-1 mb-0">
RGBA
@@ -39,4 +39,5 @@
</div>
</div>
</t>
</templates>
+248
View File
@@ -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);
};
@@ -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,
};
@@ -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);
},
});
+24 -14
View File
@@ -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'
@@ -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``;
@@ -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``;
@@ -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``;
@@ -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 class="css_editable_mode_display"></div>
<div class="media_iframe_video_size" contenteditable="false"></div>
<iframe frameborder="0" contenteditable="false" allowfullscreen="allowfullscreen"></iframe>
`;
div.querySelector('iframe').src = video.src;
return div;
});
}
}
@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<div t-name="web_editor.videoWrapper" t-att-data-oe-expression="src">
<div class="css_editable_mode_display"/>
<div class="media_iframe_video_size" contenteditable="false"/>
<iframe t-att-src="src" frameborder="0" contenteditable="false" allowfullscreen="allowfullscreen"/>
</div>
<t t-name="web_editor.VideoOption" owl="1">
<div class="mb-1">
@@ -1,16 +1,11 @@
/** @odoo-module **/
import legacyEnv from 'web.commonEnv';
import { ComponentAdapter } from 'web.OwlCompatibility';
import { registry } from "@web/core/registry";
import { _lt } from "@web/core/l10n/translation";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
import { getWysiwygClass } from 'web_editor.loader';
import { QWebPlugin } from '@web_editor/js/backend/QWebPlugin';
import { TranslationButton } from "@web/views/fields/translation_button";
import { useDynamicPlaceholder } from "@web/views/fields/dynamic_placeholder_hook";
import { QWeb } from 'web.core';
import ajax from 'web.ajax';
import {
useBus,
useService,
@@ -22,11 +17,10 @@ import {
getRangePosition
} from '@web_editor/js/editor/odoo-editor/src/utils/utils';
import { toInline } from 'web_editor.convertInline';
import { loadJS } from '@web/core/assets';
import { loadJS, getBundle } from '@web/core/assets';
import {
Component,
useRef,
useSubEnv,
useState,
onWillStart,
onMounted,
@@ -35,46 +29,31 @@ import {
onWillUnmount,
} from "@odoo/owl";
import { uniqueId } from '@web/core/utils/functions';
export class HtmlFieldWysiwygAdapterComponent extends ComponentAdapter {
setup() {
super.setup();
useSubEnv(legacyEnv);
let started = false;
onMounted(() => {
if (!started) {
this.props.startWysiwyg(this.widget);
started = true;
}
});
}
updateWidget(newProps) {
const lastValue = String(this.props.widgetArgs[0].value || '');
const lastRecordInfo = this.props.widgetArgs[0].recordInfo;
const lastCollaborationChannel = this.props.widgetArgs[0].collaborationChannel;
const newValue = String(newProps.widgetArgs[0].value || '');
const newRecordInfo = newProps.widgetArgs[0].recordInfo;
const newCollaborationChannel = newProps.widgetArgs[0].collaborationChannel;
if (
(
stripHistoryIds(newValue) !== stripHistoryIds(newProps.editingValue) &&
stripHistoryIds(lastValue) !== stripHistoryIds(newValue)
) ||
JSON.stringify(lastRecordInfo) !== JSON.stringify(newRecordInfo) ||
JSON.stringify(lastCollaborationChannel) !== JSON.stringify(newCollaborationChannel)
)
{
this.widget.resetEditor(newValue, newProps.widgetArgs[0]);
this.env.onWysiwygReset && this.env.onWysiwygReset();
}
}
renderWidget() {}
}
import { Wysiwyg, stripHistoryIds } from '../wysiwyg/wysiwyg';
export class HtmlField extends Component {
static template = "web_editor.HtmlField";
static components = {
TranslationButton,
Wysiwyg,
};
static defaultProps = { dynamicPlaceholder: false };
static props = {
...standardFieldProps,
placeholder: { type: String, optional: true },
codeview: { type: Boolean, optional: true },
isCollaborative: { type: Boolean, optional: true },
dynamicPlaceholder: { type: Boolean, optional: true, default: false },
dynamicPlaceholderModelReferenceField: { type: String, optional: true },
cssReadonlyAssetId: { type: String, optional: true },
cssEditAssetId: { type: String, optional: true },
isInlineStyle: { type: Boolean, optional: true },
sandboxedPreview: {type: Boolean, optional: true},
wrapper: { type: String, optional: true },
wysiwygOptions: { type: Object },
hasReadonlyModifiers: { type: Boolean, optional: true },
};
setup() {
this.containsComplexHTML = this.computeContainsComplexHTML();
this.sandboxedPreview = this.props.sandboxedPreview || this.containsComplexHTML;
@@ -109,13 +88,12 @@ export class HtmlField extends Component {
this._onUpdateIframeId = "onLoad_" + uniqueId("FieldHtml");
onWillStart(async () => {
this.Wysiwyg = await this._getWysiwygClass();
if (this.props.cssReadonlyAssetId) {
this.cssReadonlyAsset = await ajax.loadAsset(this.props.cssReadonlyAssetId);
this.cssReadonlyAsset = await getBundle(this.props.cssReadonlyAssetId);
}
if (this.props.cssEditAssetId || this.props.isInlineStyle) {
await loadJS('/web_editor/static/lib/html2canvas.js');
this.cssEditAsset = await ajax.loadAsset(this.props.cssEditAssetId || 'web_editor.assets_edit_html_field');
await getBundle(this.props.cssEditAssetId || 'web_editor.assets_edit_html_field');
}
});
this._lastRecordInfo = {
@@ -235,7 +213,7 @@ export class HtmlField extends Component {
}
}
const wysiwygOptions = { ...this.props.wysiwygOptions };
const wysiwygOptions = {...this.props.wysiwygOptions};
const { sanitize_tags, sanitize } = this.props.record.fields[this.props.name];
if (sanitize_tags || (sanitize_tags === undefined && sanitize)) {
wysiwygOptions.allowCommandVideo = false; // Tag-sanitized fields remove videos.
@@ -288,14 +266,6 @@ export class HtmlField extends Component {
}
return selectionIsInForbidenSnippet ? commands.filter((o) => o.title !== "Dynamic Placeholder") : commands;
}
get translationButtonWrapperStyle() {
return `
font-size: 15px;
position: absolute;
right: ${this.props.codeview ? '40px' : '5px'};
top: 5px;
`;
}
getEditingValue () {
const codeViewEl = this._getCodeViewEl();
@@ -335,7 +305,7 @@ export class HtmlField extends Component {
</button>
</div>
`);
this.wysiwyg.toolbar.$el.append($codeviewButtonToolbar);
this.wysiwyg.toolbarEl.append($codeviewButtonToolbar[0]);
$codeviewButtonToolbar.click(this.toggleCodeView.bind(this));
}
this.wysiwyg.odooEditor.addEventListener("historyStep", () =>
@@ -353,6 +323,7 @@ export class HtmlField extends Component {
if (this.wysiwyg) {
this.wysiwyg.odooEditor.observerUnactive('toggleCodeView');
if (this.state.showCodeView) {
this.wysiwyg.$editable.remove();
this.wysiwyg.odooEditor.toolbarHide();
const value = this.wysiwyg.getValue();
this.props.record.update({ [this.props.name]: value });
@@ -447,9 +418,6 @@ export class HtmlField extends Component {
}
this.iframePromise = new Promise((resolve) => {
let value = this.props.record.data[this.props.name];
if (this.props.wrapper) {
value = this._wrap(value);
}
// this bug only appears on some computers with some chrome version.
let avoidDoubleLoad = 0;
@@ -494,15 +462,14 @@ export class HtmlField extends Component {
'</html>');
}
if (this.props.cssReadonlyAssetId) {
const asset = await ajax.loadAsset(this.props.cssReadonlyAssetId);
for (const cssLib of asset.cssLibs) {
for (const cssLib of this.cssReadonlyAsset.cssLibs) {
const link = cwindow.document.createElement('link');
link.setAttribute('type', 'text/css');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('href', cssLib);
cwindow.document.head.append(link);
}
for (const cssContent of asset.cssContents) {
for (const cssContent of this.cssReadonlyAsset.cssContents) {
const style = cwindow.document.createElement('style');
style.setAttribute('type', 'text/css');
const textNode = cwindow.document.createTextNode(cssContent);
@@ -545,32 +512,6 @@ export class HtmlField extends Component {
});
return this.iframePromise;
}
/**
* Wrap HTML in order to create a custom display.
*
* The wrapper (this.props.wrapper) must be a static
* XML template with content id="wrapper".
*
* @private
* @param {string} html content
* @returns {string} html content
*/
_wrap(html) {
return $(QWeb.render(this.props.wrapper))
.find('#wrapper').html(html)
.end().prop('outerHTML');
}
/**
* Move HTML contents out of their wrapper.
*
* @private
* @param {string} html content
* @returns {string} html content
*/
_unWrap(html) {
const $wrapper = $(html).find('#wrapper');
return $wrapper.length ? $wrapper.html() : html;
}
/**
* Converts CSS dependencies to CSS-independent HTML.
* - CSS display for attachment link -> real image
@@ -595,9 +536,6 @@ export class HtmlField extends Component {
this.wysiwyg.setValue($editable.html());
this.wysiwyg.odooEditor.sanitize(this.wysiwyg.odooEditor.editable);
}
async _getWysiwygClass() {
return getWysiwygClass();
}
_onAttachmentChange(attachment) {
// This only needs to happen for the composer for now
if (!(this.props.record.fieldNames.includes('attachment_ids') && this.props.record.resModel === 'mail.compose.message')) {
@@ -674,28 +612,6 @@ export class HtmlField extends Component {
}
}
HtmlField.template = "web_editor.HtmlField";
HtmlField.components = {
TranslationButton,
HtmlFieldWysiwygAdapterComponent,
};
HtmlField.defaultProps = { dynamicPlaceholder: false };
HtmlField.props = {
...standardFieldProps,
placeholder: { type: String, optional: true },
codeview: { type: Boolean, optional: true },
isCollaborative: { type: Boolean, optional: true },
dynamicPlaceholder: { type: Boolean, optional: true, default: false },
dynamicPlaceholderModelReferenceField: { type: String, optional: true },
cssReadonlyAssetId: { type: String, optional: true },
cssEditAssetId: { type: String, optional: true },
isInlineStyle: { type: Boolean, optional: true },
sandboxedPreview: {type: Boolean, optional: true},
wrapper: { type: String, optional: true },
wysiwygOptions: { type: Object },
hasReadonlyModifiers: { type: Boolean, optional: true },
};
export const htmlField = {
component: HtmlField,
displayName: _lt("Html"),
@@ -754,7 +670,9 @@ export const htmlField = {
noVideos: 'noVideos' in options ? options.noVideos : true,
useMediaLibrary: true,
},
linkForceNewWindow: true,
linkOptions: {
forceNewWindow: true,
},
tabsize: 0,
height: options.height,
minHeight: options.minHeight,
@@ -793,7 +711,6 @@ export const htmlField = {
dynamicPlaceholderModelReferenceField: options?.dynamic_placeholder_model_reference_field || "",
cssEditAssetId: options.cssEdit,
isInlineStyle: options['style-inline'],
wrapper: options.wrapper,
wysiwygOptions,
hasReadonlyModifiers: dynamicInfo.readonly,
@@ -803,10 +720,6 @@ export const htmlField = {
registry.category("fields").add("html", htmlField, { force: true });
function stripHistoryIds(value) {
return value && value.replace(/\sdata-last-history-steps="[^"]*?"/, '') || value;
}
// Ensure all links are opened in a new tab.
const retargetLinks = (container) => {
for (const link of container.querySelectorAll('a')) {
@@ -16,10 +16,10 @@
<textarea t-ref="codeView" class="o_codeview" t-att-value="markupValue"/>
</t>
<t t-else="">
<HtmlFieldWysiwygAdapterComponent Component="this.Wysiwyg"
<Wysiwyg
options="this.wysiwygOptions"
startWysiwyg.bind="this.startWysiwyg"
editingValue="this.currentEditingValue"
widgetArgs="[this.wysiwygOptions]" />
editingValue="this.currentEditingValue" />
</t>
<t t-if="isTranslatable">
<span t-attf-style="font-size: 15px; position: absolute; right: {{this.props.codeview ? '40px' : '5px'}}; top: 5px;">
@@ -1,6 +1,9 @@
/** @odoo-module alias=web_editor.utils **/
import {ColorpickerWidget} from "web.Colorpicker";
import {
normalizeCSSColor,
isCSSColor,
} from '@web/core/utils/colors';
let editableWindow = window;
const _setEditableWindow = (ew) => editableWindow = ew;
@@ -208,8 +211,8 @@ function _areCssValuesEqual(value1, value2, cssProp, $target) {
}
// They may be colors, normalize then re-compare the resulting string
const color1 = ColorpickerWidget.normalizeCSSColor(value1);
const color2 = ColorpickerWidget.normalizeCSSColor(value2);
const color1 = normalizeCSSColor(value1);
const color2 = normalizeCSSColor(value2);
if (color1 === color2) {
return true;
}
@@ -309,7 +312,7 @@ function _getCSSVariableValue(key, htmlStyle) {
// Get trimmed value from the HTML element
let value = htmlStyle.getPropertyValue(`--${key}`).trim();
// If it is a color value, it needs to be normalized
value = ColorpickerWidget.normalizeCSSColor(value);
value = normalizeCSSColor(value);
// Normally scss-string values are "printed" single-quoted. That way no
// magic conversation is needed when customizing a variable: either save it
// quoted for strings or non quoted for colors, numbers, etc. However,
@@ -325,7 +328,7 @@ function _getCSSVariableValue(key, htmlStyle) {
* @returns {string} the normalized color
*/
function _normalizeColor(color) {
if (ColorpickerWidget.isCSSColor(color)) {
if (isCSSColor(color)) {
return color;
}
return _getCSSVariableValue(color);
@@ -1,5 +1,5 @@
/** @odoo-module alias=web_editor.custom_colors **/
/** @odoo-module **/
// These colors are already normalized as per normalizeCSSColor in web.Colorpicker
export default [
@@ -755,6 +755,7 @@ export class OdooEditor extends EventTarget {
});
}
}
this._updateToolbar();
}
resetContent(value) {
@@ -7,7 +7,7 @@ import dom from "web.dom";
import {Markup, confine} from "web.utils";
import Widget from "web.Widget";
import options from "web_editor.snippets.options";
import {ColorPaletteWidget} from "web_editor.ColorPalette";
import { ColorPalette } from '@web_editor/js/wysiwyg/widgets/color_palette';
import SmoothScrollOnDrag from "web.smooth_scroll_on_drag";
import {getCSSVariableValue} from "web_editor.utils";
import * as gridUtils from "@web_editor/js/common/grid_layout_utils";
@@ -18,112 +18,18 @@ import { debounce, throttleForAnimation } from "@web/core/utils/timing";
import { uniqueId } from "@web/core/utils/functions";
import { sortBy, unique } from "@web/core/utils/arrays";
import { browser } from "@web/core/browser/browser";
import { ComponentWrapper } from "web.OwlCompatibility";
import { Toolbar } from "@web_editor/js/editor/toolbar";
import {
Component,
xml,
} from "@odoo/owl";
import { LinkTools } from '@web_editor/js/wysiwyg/widgets/link_tools';
var _t = core._t;
let cacheSnippetTemplate = {};
// jQuery extensions
$.extend($.expr[':'], {
o_editable: function (node, i, m) {
while (node) {
if (node.className && typeof node.className === "string") {
if (node.className.indexOf('o_not_editable') !== -1) {
return false;
}
if (node.className.indexOf('o_editable') !== -1) {
return true;
}
}
node = node.parentNode;
}
return false;
},
});
function firstChild(node) {
while (node.firstChild) {
node = node.firstChild;
}
return node;
}
function lastChild(node) {
while (node.lastChild) {
node = node.lastChild;
}
return node;
}
function nodeLength(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue.length;
} else {
return node.childNodes.length;
}
}
$.fn.extend({
focusIn: function () {
if (this.length) {
const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
const node = firstChild(this[0]);
range.setStart(node, 0);
range.setEnd(node, 0);
selection.addRange(range);
}
return this;
},
focusInEnd: function () {
if (this.length) {
const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
const node = lastChild(this[0]);
const length = nodeLength(node);
range.setStart(node, length);
range.setEnd(node, length);
selection.addRange(range);
}
return this;
},
selectContent: function () {
if (this.length && !this[0].hasChildNodes()) {
return this.selectElement();
}
if (this.length) {
const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
range.setStart(this[0].firstChild, 0);
range.setEnd(this[0].lastChild, this[0].lastChild.length);
selection.addRange(range);
}
return this;
},
selectElement: function () {
if (this.length) {
const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const element = this[0];
const parent = element.parentNode;
const offsetStart = Array.from(parent.childNodes).indexOf(element);
const range = new Range();
range.setStart(parent, offsetStart);
range.setEnd(parent, offsetStart + 1);
selection.addRange(range);
}
return this;
},
});
var globalSelector = {
closest: () => $(),
all: () => $(),
@@ -366,8 +272,14 @@ var SnippetEditor = Widget.extend({
// be outside of the viewport (the whole header during an effect for
// example).
const rect = targetEl.getBoundingClientRect();
const vpWidth = targetEl.ownerDocument.defaultView.innerWidth || document.documentElement.clientWidth;
const vpHeight = targetEl.ownerDocument.defaultView.innerHeight || document.documentElement.clientHeight;
// TODO: At that point, targetEl.ownerDocument.defaultView should not be
// null. However, there is a non-deterministic race condition that can
// result in the document being unloaded from the iframe before the handlers
// of the snippets menu are removed, thus triggering a traceback if the
// optional chaining operator is removed. This can be reproduced
// non-deterministically on runbot by running the edit_menus tour.
const vpWidth = targetEl.ownerDocument.defaultView?.innerWidth || document.documentElement.clientWidth;
const vpHeight = targetEl.ownerDocument.defaultView?.innerHeight || document.documentElement.clientHeight;
const isInViewport = (
rect.bottom > -0.1 &&
rect.right > -0.1 &&
@@ -1782,7 +1694,6 @@ var SnippetsMenu = Widget.extend({
'snippet_option_update': '_onSnippetOptionUpdate',
'snippet_option_visibility_update': '_onSnippetOptionVisibilityUpdate',
'snippet_thumbnail_url_request': '_onSnippetThumbnailURLRequest',
'reload_snippet_dropzones': '_disableUndroppableSnippets',
'request_save': '_onSaveRequest',
'hide_overlay': '_onHideOverlay',
'block_preview_overlays': '_onBlockPreviewOverlays',
@@ -1855,7 +1766,13 @@ var SnippetsMenu = Widget.extend({
// widget have huge chances of being used by the user (clicking on any
// text will load it). The colorpalette itself will do the actual
// waiting of the loading completion.
ColorPaletteWidget.loadDependencies(this);
ColorPalette.loadDependencies(() => {
return this._rpc({
model: 'ir.ui.view',
method: 'render_public_asset',
args: ['web_editor.colorpicker', {}],
});
});
return this._super(...arguments);
},
/**
@@ -2108,6 +2025,7 @@ var SnippetsMenu = Widget.extend({
this.el.ownerDocument.body.classList.remove('editor_has_snippets');
// Dispose BS tooltips.
this.tooltips.dispose();
options.clearM2oRpcCache();
},
//--------------------------------------------------------------------------
@@ -2283,6 +2201,9 @@ var SnippetsMenu = Widget.extend({
execWithLoadingEffect(action, contentLoading = true, delay = 500) {
return this._execWithLoadingEffect(...arguments);
},
reload_snippet_dropzones() {
this._disableUndroppableSnippets();
},
//--------------------------------------------------------------------------
// Private
@@ -2501,12 +2422,7 @@ var SnippetsMenu = Widget.extend({
this.invisibleDOMMap = new Map();
const $invisibleDOMPanelEl = $(this.invisibleDOMPanelEl);
$invisibleDOMPanelEl.find('.o_we_invisible_entry').remove();
let isMobile;
this.trigger_up('service_context_get', {
callback: (ctx) => {
isMobile = ctx['isMobile'];
},
});
const isMobile = this.options.wysiwyg.websiteService?.context.isMobile;
const invisibleSelector = `.o_snippet_invisible, ${isMobile ? '.o_snippet_mobile_invisible' : '.o_snippet_desktop_invisible'}`;
const $selector = this.options.enableTranslation ? this.$body : globalSelector.all();
let $invisibleSnippets = $selector.find(invisibleSelector).addBack(invisibleSelector);
@@ -2706,14 +2622,24 @@ var SnippetsMenu = Widget.extend({
// TODO: this should be reviewed to be done another way: we
// should avoid focusing something here while it is being
// rendered elsewhere.
const linkTools = this.options.wysiwyg.linkTools;
if (linkTools && this._currentTab === this.tabs.OPTIONS
&& !linkTools.noFocusUrl) {
// Wait for `linkTools` potential in-progress rendering
// before focusing the URL input on `snippetsMenu` (this
// prevents race condition for automated testing).
await linkTools.renderingPromise;
linkTools.focusUrl();
if (this.options.wysiwyg.state.linkToolProps) {
let inputElement = document.querySelector('#o_link_dialog_url_input');
if (!inputElement) {
// Wait for `linkTools` potential in-progress rendering
// before focusing the URL input on `snippetsMenu` (this
// prevents race condition for automated testing).
inputElement = await new Promise((resolve) => {
const observer = new MutationObserver(() => {
const inputElement = document.querySelector('#o_link_dialog_url_input');
if (inputElement) {
observer.disconnect();
resolve(inputElement);
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
inputElement.focus();
}
return editor;
});
@@ -4322,7 +4248,7 @@ var SnippetsMenu = Widget.extend({
async _onUpdateInvisibleDom() {
await this._updateInvisibleDOM();
},
_addToolbar(toolbarMode = "text") {
async _addToolbar(toolbarMode = "text") {
if (this.folded) {
return;
}
@@ -4339,22 +4265,63 @@ var SnippetsMenu = Widget.extend({
break;
}
this.options.wysiwyg.toolbar.el.classList.remove('oe-floating');
if (!this._toolbarWrapperEl) {
this._toolbarWrapperEl = document.createElement('div');
this._toolbarWrapperEl.classList.add('o_we_toolbar_wrapper');
class WebsiteToolbar extends Component {
static components = { Toolbar, LinkTools };
static template = xml`
<Toolbar t-props="props.wysiwygState.toolbarProps">
<t t-if="props.wysiwygState.linkToolProps">
<LinkTools t-props="props.wysiwygState.linkToolProps" />
</t>
</Toolbar>
`;
static props = {
wysiwygState: Object,
};
}
const toolbarWrapper = new ComponentWrapper(this, WebsiteToolbar, {
wysiwygState: this.options.wysiwyg.state,
});
// Add the toolbarWrapperEl to the dom for owl to properly mount the
// Toolbar.
document.body.append(this._toolbarWrapperEl);
this._toolbarWrapperEl.style.display = 'none';
await toolbarWrapper.mount(this._toolbarWrapperEl);
this._toolbarWrapperEl.style.display = 'contents';
const toolbarEl = this._toolbarWrapperEl.firstChild;
toolbarEl.classList.remove('oe-floating');
this.options.wysiwyg.toolbarEl.style.display = 'none';
this.options.wysiwyg.setupToolbar(toolbarEl);
}
// Create toolbar custom container.
this._$toolbarContainer = $('<WE-CUSTOMIZEBLOCK-OPTIONS id="o_we_editor_toolbar_container"/>');
const $title = $("<we-title><span>" + titleText + "</span></we-title>");
this._$toolbarContainer.append($title);
this._$toolbarContainer.append(this.options.wysiwyg.toolbar.$el);
// In case, the snippetEditor is inside an iframe, rebind the dropdown
// from the iframe.
for (const dropdown of this._toolbarWrapperEl.querySelectorAll('.colorpicker-group')) {
const $ = dropdown.ownerDocument.defaultView.$;
const $dropdown = $(dropdown);
$dropdown.off('show.bs.dropdown');
$dropdown.on('show.bs.dropdown', () => {
this.options.wysiwyg.onColorpaletteDropdownShow(dropdown.dataset.colorType);
});
$dropdown.off('hide.bs.dropdown');
$dropdown.on('hide.bs.dropdown', (ev) => this.options.wysiwyg.onColorpaletteDropdownHide(ev));
}
this._$toolbarContainer.append(this._toolbarWrapperEl);
$(this.customizePanel).append(this._$toolbarContainer);
// Create table-options custom container.
const $customizeTableBlock = $(QWeb.render('web_editor.toolbar.table-options'));
this.options.wysiwyg.odooEditor.bindExecCommand($customizeTableBlock[0]);
$(this.customizePanel).append($customizeTableBlock);
this._$removeFormatButton = this.options.wysiwyg.toolbar.$el.find('#removeFormat');
$title.append(this._$removeFormatButton);
this._$toolbarContainer.append(this.options.wysiwyg.toolbar.$el);
$title.append(this._toolbarWrapperEl.querySelector('#removeFormat'));
this._$toolbarContainer.append(this._toolbarWrapperEl);
this._checkEditorToolbarVisibility();
},
@@ -1,9 +1,8 @@
/** @odoo-module alias=web_editor.snippets.options **/
import { ComponentWrapper } from "web.OwlCompatibility";
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog";
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog_wrapper";
import core from "web.core";
import {ColorpickerWidget} from "web.Colorpicker";
import Dialog from "web.Dialog";
import {scrollTo} from "web.dom";
import rpc from "web.rpc";
@@ -11,7 +10,7 @@ import time from "web.time";
import { throttleForAnimation, debounce } from "@web/core/utils/timing";
import utils from "web.utils";
import Widget from "web.Widget";
import { ColorPaletteWidget } from "web_editor.ColorPalette";
import { ColorPalette } from '@web_editor/js/wysiwyg/widgets/color_palette';
import weUtils from "web_editor.utils";
import * as gridUtils from "@web_editor/js/common/grid_layout_utils";
const {
@@ -22,7 +21,7 @@ const {
DEFAULT_PALETTE,
isBackgroundImageAttribute,
} = weUtils;
import weWidgets from "wysiwyg.widgets";
import { ImageCrop } from '@web_editor/js/wysiwyg/widgets/image_crop';
import {
loadImage,
loadImageInfo,
@@ -38,6 +37,11 @@ import {SIZES, MEDIAS_BREAKPOINTS} from "@web/core/ui/ui_service";
import { sprintf } from "@web/core/utils/strings";
import { uniqueId } from "@web/core/utils/functions";
import { pick } from "@web/core/utils/objects";
import {
isCSSColor,
convertCSSColorToRgba,
normalizeCSSColor,
} from '@web/core/utils/colors';
var qweb = core.qweb;
var _t = core._t;
@@ -1404,13 +1408,6 @@ const MultiUserValueWidget = UserValueWidget.extend({
const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
className: (SelectUserValueWidget.prototype.className || '') + ' o_we_so_color_palette',
custom_events: Object.assign({}, SelectUserValueWidget.prototype.custom_events, {
'custom_color_picked': '_onCustomColorPicked',
'color_picked': '_onColorPicked',
'color_hover': '_onColorHovered',
'color_leave': '_onColorLeft',
'enter_key_color_colorpicker': '_onEnterKey'
}),
/**
* @override
@@ -1419,21 +1416,20 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
const _super = this._super.bind(this);
const args = arguments;
if (this.options.dataAttributes.lazyPalette === 'true') {
// TODO review in master, this was done in stable to keep the speed
// fix as stable as possible (to have a reference to a widget even
// if not a colorPalette widget).
this.colorPalette = new Widget(this);
this.colorPalette.getColorNames = () => [];
await this.colorPalette.appendTo(document.createDocumentFragment());
} else {
if (!this.options.dataAttributes.lazyPalette === 'true') {
await this._renderColorPalette();
}
// Build the select element with a custom span to hold the color preview
this.colorPreviewEl = document.createElement('span');
this.colorPreviewEl.classList.add('o_we_color_preview');
this.options.childNodes = [this.colorPalette.el];
// todo: This div should be removed whenever possible (like when
// converting the uservaluewidget to owl).
this.colorPaletteEl = document.createElement('div');
this.colorPaletteEl.classList.add('o_we_color_palette_wrapper');
this.colorPaletteEl.style.display = 'contents';
this.colorPaletteColorNames = [];
this.options.childNodes = [this.colorPaletteEl];
this.options.valueEl = this.colorPreviewEl;
return _super(...args);
@@ -1447,8 +1443,12 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
* @override
*/
open: function () {
if (this.colorPalette.setSelectedColor) {
this.colorPalette.setSelectedColor(this._ccValue, this._value);
if (this.colorPaletteWrapper) {
this.colorPaletteWrapper?.update({
selectedCC: this._ccValue,
selectedColor: this._value,
resetTabCount: this.colorPaletteWrapper.node.component.props.props.resetTabCount + 1,
});
} else {
// TODO review in master, this does async stuff. Maybe the open
// method should now be async. This is not really robust as the
@@ -1475,7 +1475,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
*/
getMethodsParams: function () {
return Object.assign(this._super(...arguments), {
colorNames: this.colorPalette.getColorNames(),
colorNames: this.colorPaletteColorNames,
});
},
/**
@@ -1501,7 +1501,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
// in this code.
const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor');
const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible');
if ((useCssColor || cssCompatible) && !ColorpickerWidget.isCSSColor(value)) {
if ((useCssColor || cssCompatible) && !isCSSColor(value)) {
if (useCssColor) {
value = weUtils.getCSSVariableValue(value);
} else {
@@ -1543,7 +1543,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
await this._colorPaletteRenderPromise;
const classes = weUtils.computeColorClasses(this.colorPalette.getColorNames());
const classes = weUtils.computeColorClasses(this.colorPaletteColorNames);
this.colorPreviewEl.classList.remove(...classes);
this.colorPreviewEl.style.removeProperty('background-color');
this.colorPreviewEl.style.removeProperty('background-image');
@@ -1552,7 +1552,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
this.colorPreviewEl.style.backgroundColor = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')})`;
}
if (this._value) {
if (ColorpickerWidget.isCSSColor(this._value)) {
if (isCSSColor(this._value)) {
this.colorPreviewEl.style.backgroundColor = this._value;
} else if (weUtils.isColorGradient(this._value)) {
this.colorPreviewEl.style.backgroundImage = this._value;
@@ -1580,9 +1580,10 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
}
// 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);
}
this.colorPaletteWrapper?.update({
selectedCC: this._ccValue,
selectedColor: this._value,
});
},
//--------------------------------------------------------------------------
@@ -1595,8 +1596,24 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
*/
_renderColorPalette: function () {
const options = {
resetTabCount: 0,
selectedCC: this._ccValue,
selectedColor: this._value,
onSetColorNames: (colorNames) => {
this.colorPaletteColorNames = colorNames
},
getCustomColors: () => {
let result = [];
this.trigger_up('get_custom_colors', {
onSuccess: (colors) => result = colors,
});
return result;
},
onCustomColorPicked: this._onCustomColorPicked.bind(this),
onColorPicked: this._onColorPicked.bind(this),
onColorHover: this._onColorHovered.bind(this),
onColorLeave: this._onColorLeft.bind(this),
onInputEnter: this._onEnterKey.bind(this),
};
if (this.options.dataAttributes.excluded) {
options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(',');
@@ -1617,15 +1634,16 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
if (this.options.dataAttributes.selectedTab) {
options.selectedTab = this.options.dataAttributes.selectedTab;
}
const oldColorPalette = this.colorPalette;
this.colorPalette = new ColorPaletteWidget(this, options);
if (oldColorPalette) {
return this.colorPalette.insertAfter(oldColorPalette.el).then(() => {
oldColorPalette.destroy();
});
const wysiwyg = this.getParent().options.wysiwyg;
if (wysiwyg) {
options.document = this.$target[0].ownerDocument;
}
return this.colorPalette.appendTo(document.createDocumentFragment());
if (this.colorPaletteWrapper) {
this.colorPaletteWrapper.destroy();
}
this.colorPaletteWrapper = new ComponentWrapper(this, ColorPalette, options);
return this.colorPaletteWrapper.mount(this.colorPaletteEl);
},
/**
* @override
@@ -1643,48 +1661,47 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
* and set the current value. Update of this value on close
*
* @private
* @param {Event} ev
* @param {Object} params
*/
_onCustomColorPicked: function (ev) {
this._customColorValue = ev.data.color;
_onCustomColorPicked: function (params) {
this._customColorValue = params.color;
},
/**
* Called when a color button is clicked -> confirms the preview.
*
* @private
* @param {Event} ev
* @param {Object} params
*/
_onColorPicked: function (ev) {
_onColorPicked: function (params) {
this._previewCC = false;
this._previewColor = false;
this._customColorValue = false;
this._ccValue = ev.data.ccValue;
this._value = ev.data.color;
this._ccValue = params.ccValue;
this._value = params.color;
this._onUserValueChange(ev);
this._onUserValueChange();
},
/**
* Called when a color button is entered -> previews the background color.
*
* @private
* @param {Event} ev
* @param {Object} params
*/
_onColorHovered: function (ev) {
this._previewCC = ev.data.ccValue;
this._previewColor = ev.data.color;
this._onUserValuePreview(ev);
_onColorHovered: function (params) {
this._previewCC = params.ccValue;
this._previewColor = params.color;
this._onUserValuePreview();
},
/**
* Called when a color button is left -> cancels the preview.
*
* @private
* @param {Event} ev
*/
_onColorLeft: function (ev) {
_onColorLeft: function () {
this._previewCC = false;
this._previewColor = false;
this._onUserValueReset(ev);
this._onUserValueReset();
},
/**
* @private
@@ -3904,7 +3921,7 @@ const SnippetOptionWidget = Widget.extend({
return '';
}
} else {
const rgba = ColorpickerWidget.convertCSSColorToRgba(value);
const rgba = convertCSSColorToRgba(value);
if (rgba && rgba.opacity < 0.001) {
// Prevent to consider a transparent color is
// applied as background unless it is to override a
@@ -3980,7 +3997,7 @@ const SnippetOptionWidget = Widget.extend({
*/
_normalizeWidgetValue: function (value) {
value = `${value}`.trim(); // Force to a trimmed string
value = ColorpickerWidget.normalizeCSSColor(value); // If is a css color, normalize it
value = normalizeCSSColor(value); // If is a css color, normalize it
return value;
},
/**
@@ -5464,13 +5481,18 @@ registry.SnippetMove = SnippetOptionWidget.extend({
* Allows for media to be replaced.
*/
registry.ReplaceMedia = SnippetOptionWidget.extend({
init: function () {
this._super(...arguments);
this._activateLinkTool = this._activateLinkTool.bind(this);
this._deactivateLinkTool = this._deactivateLinkTool.bind(this);
},
/**
* @override
*/
onFocus() {
core.bus.on('activate_image_link_tool', this, this._activateLinkTool);
core.bus.on('deactivate_image_link_tool', this, this._deactivateLinkTool);
this.options.wysiwyg.odooEditor.addEventListener('activate_image_link_tool', this._activateLinkTool);
this.options.wysiwyg.odooEditor.addEventListener('deactivate_image_link_tool', this._deactivateLinkTool);
// When we start editing an image, rerender the UI to ensure the
// we-select that suggests the anchors is in a consistent state.
this.rerender = true;
@@ -5479,8 +5501,8 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({
* @override
*/
onBlur() {
core.bus.off('activate_image_link_tool', this, this._activateLinkTool);
core.bus.off('deactivate_image_link_tool', this, this._deactivateLinkTool);
this.options.wysiwyg.odooEditor.removeEventListener('activate_image_link_tool', this._activateLinkTool);
this.options.wysiwyg.odooEditor.removeEventListener('deactivate_image_link_tool', this._deactivateLinkTool);
},
//--------------------------------------------------------------------------
@@ -6038,7 +6060,16 @@ registry.ImageTools = ImageHandlerOption.extend({
this.trigger_up('hide_overlay');
this.trigger_up('disable_loading_effect');
const img = this._getImg();
new weWidgets.ImageCropWidget(this, img, {mimetype: this._getImageMimetype(img)}).appendTo(this.$el[0].ownerDocument.body);
const document = this.$el[0].ownerDocument;
const imageCropWrapper = new ComponentWrapper(this, ImageCrop, {
rpc: this._rpc.bind(this),
activeOnStart: true,
media: img,
mimetype: this._getImageMimetype(img),
});
const imageCropWrapperElement = document.createElement('div');
document.body.append(imageCropWrapperElement);
await imageCropWrapper.mount(imageCropWrapperElement);
await new Promise(resolve => {
this.$target.one('image_cropper_destroyed', async () => {
@@ -6047,6 +6078,7 @@ registry.ImageTools = ImageHandlerOption.extend({
}
await this._reapplyCurrentShape();
resolve();
imageCropWrapperElement.remove();
});
});
this.trigger_up('enable_loading_effect');
@@ -6088,9 +6120,24 @@ registry.ImageTools = ImageHandlerOption.extend({
*/
async resetCrop() {
const img = this._getImg();
const cropper = new weWidgets.ImageCropWidget(this, img, {mimetype: this._getImageMimetype(img)});
await cropper.appendTo(this.$el[0].ownerDocument.body);
await cropper.reset();
// Mount the ImageCrop to call the reset method. As we need the state of
// the component to be mounted before calling reset, mount it
// temporarily into the body.
const imageCropWrapper = new ComponentWrapper(this, ImageCrop, {
rpc: this._rpc.bind(this),
activeOnStart: true,
media: img,
mimetype: this._getImageMimetype(img),
});
const imageCropWrapperElement = document.createElement('div');
document.body.append(imageCropWrapperElement);
await imageCropWrapper.mount(imageCropWrapperElement);
await imageCropWrapper.componentRef.comp.mountedPromise;
await imageCropWrapper.componentRef.comp.reset();
imageCropWrapper.unmount();
imageCropWrapperElement.remove();
await this._reapplyCurrentShape();
},
/**
@@ -6425,7 +6472,7 @@ registry.ImageTools = ImageHandlerOption.extend({
* @returns {string}
*/
_getCSSColorValue(color) {
if (!color || ColorpickerWidget.isCSSColor(color)) {
if (!color || isCSSColor(color)) {
return color;
}
return weUtils.getCSSVariableValue(color);
@@ -6710,7 +6757,7 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({
let filterEl = this.$target[0].querySelector(':scope > .o_we_bg_filter');
// If the filter would be transparent, remove it / don't create it.
const rgba = widgetValue && ColorpickerWidget.convertCSSColorToRgba(widgetValue);
const rgba = widgetValue && convertCSSColorToRgba(widgetValue);
if (!widgetValue || rgba && rgba.opacity < 0.001) {
if (filterEl) {
filterEl.remove();
@@ -1,27 +1,102 @@
/** @odoo-module alias=web_editor.toolbar **/
/** @odoo-module **/
import Widget from "web.Widget";
import config from "web.config";
import { isMobileOS } from "@web/core/browser/feature_detection";
import { ColorPalette } from "@web_editor/js/wysiwyg/widgets/color_palette";
import {
Component,
onMounted,
useRef,
} from "@odoo/owl";
export class Toolbar extends Component {
static template = 'web_editor.toolbar';
static components = { ColorPalette };
static props = {
dropDirection: { type: String, optional: true },
showChecklist: { type: Boolean, optional: true },
showColors: { type: Boolean, optional: true },
showFontSize: { type: Boolean, optional: true },
showHistory: { type: Boolean, optional: true },
showStyle: { type: Boolean, optional: true },
showJustify: { type: Boolean, optional: true },
showList: { type: Boolean, optional: true },
showLink: { type: Boolean, optional: true },
showImageShape: { type: Boolean, optional: true },
showImagePadding: { type: Boolean, optional: true },
showImageWidth: { type: Boolean, optional: true },
showImageEdit: { type: Boolean, optional: true },
showHeading1: { type: Boolean, optional: true },
showHeading2: { type: Boolean, optional: true },
showHeading3: { type: Boolean, optional: true },
showHeading4: { type: Boolean, optional: true },
showHeading5: { type: Boolean, optional: true },
showHeading6: { type: Boolean, optional: true },
onColorpaletteDropdownShow: { type: Function, optional: true },
onColorpaletteDropdownHide: { type: Function, optional: true },
textColorPaletteProps: { type: Object },
backgroundColorPaletteProps: { type: Object },
slots: { type: Object, optional: true },
};
static defaultProps = {
dropDirection: 'dropdown',
showChecklist: true,
showColors: true,
showFontSize: true,
showHistory: false,
showStyle: true,
showJustify: true,
showList: true,
showLink: true,
showImageShape: true,
showImagePadding: true,
showImageWidth: true,
showImageEdit: true,
showHeading1: true,
showHeading2: true,
showHeading3: true,
showHeading4: true,
showHeading5: true,
showHeading6: true,
onColorpaletteDropdownShow: () => {},
onColorpaletteDropdownHide: () => {},
};
colorDropdownRef = {
text: useRef("textColorpickerDropdown"),
background: useRef("backgroundColorpaletteDropdown"),
}
setup() {
onMounted(() => {
for (const [colorType, ref] of Object.entries(this.colorDropdownRef)) {
const dropdown = ref.el;
if (!dropdown) continue;
// If the element is within an iframe, access the jquery loaded in
// the iframe because it is the one who will trigger the dropdown
// events (i.e hide.bs.dropdown and show.bs.dropdown).
const $ = dropdown.ownerDocument.defaultView.$;
const $dropdown = $(dropdown);
$dropdown.on('show.bs.dropdown', () => {
this.props.onColorpaletteDropdownShow(colorType);
});
$dropdown.on('hide.bs.dropdown', (ev) => this.props.onColorpaletteDropdownHide(ev));
}
});
}
const Toolbar = Widget.extend({
/**
* @constructor
* @param {Widget} parent
* @param {string} contents
*/
init: function (parent, template = 'web_editor.toolbar') {
this._super.apply(this, arguments);
this.template = template;
},
/**
* States whether the current environment is in mobile or not. This is
* useful in order to customize the template rendering for mobile view.
*
* @returns {boolean}
*/
isMobile() {
return config.device.isMobile;
},
});
export default Toolbar;
return isMobileOS();
}
}
@@ -0,0 +1,63 @@
/** @odoo-module **/
import { getBundle, loadBundle } from "@web/core/assets";
import { useWowlService } from '@web/legacy/utils';
import { requireLegacyModule } from '@web_editor/js/frontend/loader';
export async function loadWysiwygFromTextarea(parent, textarea, options) {
var loading = textarea.nextElementSibling;
if (loading && !loading.classList.contains('o_wysiwyg_loading')) {
loading = null;
}
const $textarea = $(textarea);
const currentOptions = Object.assign({}, options);
currentOptions.value = currentOptions.value || $textarea.val() || '';
if (!currentOptions.value.trim()) {
currentOptions.value = '<p><br></p>';
}
const { ComponentWrapper } = await requireLegacyModule('web.OwlCompatibility')
const { Wysiwyg } = await requireLegacyModule('@web_editor/js/wysiwyg/wysiwyg', async () => {
const bundle = await getBundle('web_editor.assets_wysiwyg');
await loadBundle(bundle);
});
let wysiwyg;
class LegacyWysiwyg extends Wysiwyg {
constructor(...args) {
super(...args);
wysiwyg = this;
}
_useService(serviceName) {
return useWowlService(serviceName);
}
}
const $wysiwygWrapper = $textarea.closest('.o_wysiwyg_textarea_wrapper');
const $form = $textarea.closest('form');
// hide and append the $textarea in $form so it's value will be send
// through the form.
$textarea.hide();
$form.append($textarea);
$wysiwygWrapper.html('');
const wysiwygWrapper = $wysiwygWrapper[0];
const componentWrapper = new ComponentWrapper(parent, LegacyWysiwyg, {
options: currentOptions,
editingValue: currentOptions.value,
});
componentWrapper.mount(wysiwygWrapper);
$form.find('.note-editable').data('wysiwyg', wysiwyg);
// o_we_selected_image has not always been removed when
// saving a post so we need the line below to remove it if it is present.
$form.find('.note-editable').find('img.o_we_selected_image').removeClass('o_we_selected_image');
$form.on('click', 'button[type=submit]', (e) => {
$form.find('.note-editable').find('img.o_we_selected_image').removeClass('o_we_selected_image');
// float-start class messes up the post layout OPW 769721
$form.find('.note-editable').find('img.float-start').removeClass('float-start');
$textarea.html(wysiwyg.getValue());
});
return wysiwyg;
};
@@ -1,79 +1,23 @@
/** @odoo-module alias=web_editor.loader **/
/** @odoo-module **/
import { getBundle, loadBundle } from "@web/core/assets";
const exports = {};
async function loadWysiwyg(additionnalAssets=[]) {
const xmlids = ['web_editor.assets_wysiwyg', ...additionnalAssets];
export async function loadLegacyWysiwygAssets(additionnalAssets=[]) {
const xmlids = ['web_editor.assets_legacy_wysiwyg', ...additionnalAssets];
for (const xmlid of xmlids) {
const assets = await getBundle(xmlid);
await loadBundle(assets);
}
}
exports.loadWysiwyg = loadWysiwyg;
/**
* Load the assets and create a wysiwyg.
*
* @param {Widget} parent The wysiwyg parent
* @param {object} options
* @param {object} options.wysiwygOptions The wysiwyg options
* @param {string} options.moduleName The wysiwyg module name
* @param {object} options.additionnalAssets The additional assets
*/
exports.createWysiwyg = async (parent, options = {}) => {
const Wysiwyg = await getWysiwygClass(options);
return new Wysiwyg(parent, options.wysiwygOptions);
};
async function getWysiwygClass({moduleName = 'web_editor.wysiwyg', additionnalAssets = []} = {}) {
export async function requireLegacyModule(moduleName, loadCallback = () => {}) {
if (!(await odoo.ready(moduleName))) {
await loadWysiwyg(additionnalAssets);
await loadCallback();
await odoo.ready(moduleName);
}
return odoo.__DEBUG__.services[moduleName];
}
exports.getWysiwygClass = getWysiwygClass;
exports.loadFromTextarea = async (parent, textarea, options) => {
var loading = textarea.nextElementSibling;
if (loading && !loading.classList.contains('o_wysiwyg_loading')) {
loading = null;
}
const $textarea = $(textarea);
const currentOptions = Object.assign({}, options);
currentOptions.value = currentOptions.value || $textarea.val() || '';
if (!currentOptions.value.trim()) {
currentOptions.value = '<p><br></p>';
}
const Wysiwyg = await getWysiwygClass();
const wysiwyg = new Wysiwyg(parent, currentOptions);
const $wysiwygWrapper = $textarea.closest('.o_wysiwyg_textarea_wrapper');
const $form = $textarea.closest('form');
// hide and append the $textarea in $form so it's value will be send
// through the form.
$textarea.hide();
$form.append($textarea);
$wysiwygWrapper.html('');
await wysiwyg.appendTo($wysiwygWrapper);
$form.find('.note-editable').data('wysiwyg', wysiwyg);
// o_we_selected_image has not always been removed when
// saving a post so we need the line below to remove it if it is present.
$form.find('.note-editable').find('img.o_we_selected_image').removeClass('o_we_selected_image');
$form.on('click', 'button[type=submit]', (e) => {
$form.find('.note-editable').find('img.o_we_selected_image').removeClass('o_we_selected_image');
// float-start class messes up the post layout OPW 769721
$form.find('.note-editable').find('img.float-start').removeClass('float-start');
$textarea.html(wysiwyg.getValue());
});
return wysiwyg;
};
export default exports;
export async function requireWysiwygLegacyModule(moduleName) {
return requireLegacyModule(moduleName, loadLegacyWysiwygAssets);
}
@@ -6,7 +6,7 @@
* tweaked as soon as the DOM is ready so that they appear to be loading.
*
* They must then be loaded using standard Odoo modules system. In particular,
* @see web_editor.loader
* @see @web_editor/js/frontend/loadWysiwygFromTextarea
*/
document.addEventListener('DOMContentLoaded', () => {
@@ -0,0 +1,10 @@
/* @odoo-module */
import { Component } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
export class ConflictDialog extends Component {
static components = { Dialog };
static props = ["close","content"];
static template = 'web_editor.ConflictDialog';
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_editor.ConflictDialog" owl="1">
<Dialog size="'xl'" title="'Content conflict'">
<div>
<div style="color: red;">
<p>
There is a conflict between your version and the one in the database.
</p>
<p>
The version from the database will be used.
If you need to keep your changes, copy the content below and edit the new document.
</p>
<p style="font-weight: bold;">
Warning: after closing this dialog, the version you were working on will be discarded and will never be available anymore.
</p>
</div>
</div>
<t t-out="props.content"/>
</Dialog>
</t>
</templates>
@@ -1,90 +0,0 @@
/** @odoo-module alias=wysiwyg.widgets.Dialog **/
import config from "web.config";
import core from "web.core";
import Dialog from "web.Dialog";
var _t = core._t;
/**
* Extend Dialog class to handle save/cancel of edition components.
*/
var WysiwygDialog = Dialog.extend({
/**
* @constructor
*/
init: function (parent, options) {
this.options = options || {};
if (config.device.isMobile) {
options.fullscreen = true;
}
this._super(parent, Object.assign({}, {
buttons: [{
text: this.options.save_text || _t("Save"),
classes: 'btn-primary',
click: this.save,
},
{
text: _t("Discard"),
close: true,
}
]
}, this.options));
this.destroyAction = 'cancel';
this.closeOnSave = true;
var self = this;
this.opened(function () {
const selector = options.focusField
? `input[name=${options.focusField}]`
: 'input:visible:first';
self.$(selector).focus();
self.$el.closest('.modal').addClass('o_web_editor_dialog');
self.$el.closest('.modal').on('hidden.bs.modal', self.options.onClose);
});
this.on('closed', this, function () {
self._toggleFullScreen();
if (this.destroyAction) {
this.trigger(this.destroyAction, this.final_data || null);
}
});
},
/**
* Only use on config.device.isMobile, it's used by mass mailing to allow the dialog opening on fullscreen
* @private
*/
_toggleFullScreen: function() {
if (config.device.isMobile && !this.hasFullScreen) {
$('#iframe_target[isMobile="true"] #web_editor-top-edit .o_fullscreen').click();
}
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Called when the dialog is saved.
* Optionally closes the dialog.
*/
save: function () {
if (this.closeOnSave) {
this.destroyAction = "save";
this.close();
} else {
this.destroyAction = null;
this.trigger('save', this.final_data || null);
}
},
/**
* @override
* @returns {*}
*/
open: function() {
this.hasFullScreen = $(window.top.document.body).hasClass('o_field_widgetTextHtml_fullscreen');
this._toggleFullScreen();
return this._super.apply(this, arguments);
},
});
export default WysiwygDialog;
@@ -2,7 +2,7 @@
import { registry } from '@web/core/registry'
import { HotkeyCommandItem } from '@web/core/commands/default_providers'
import Wysiwyg from 'web_editor.wysiwyg'
import { Wysiwyg } from '@web_editor/js/wysiwyg/wysiwyg';
// The only way to know if an editor is under focus when the command palette
// open is to look if there in a selection within a wysiwyg editor in the page.
@@ -1,6 +0,0 @@
/** @odoo-module alias=web_editor.widget **/
import Dialog from "wysiwyg.widgets.Dialog";
import LinkDialog from "wysiwyg.widgets.LinkDialog";
export default { Dialog, LinkDialog };
@@ -1,49 +1,45 @@
/** @odoo-module alias=wysiwyg.widgets.AltDialog **/
import core from "web.core";
import Dialog from "wysiwyg.widgets.Dialog";
var _t = core._t;
/**
* Let users change the alt & title of a media.
*/
var AltDialog = Dialog.extend({
template: 'wysiwyg.widgets.alt',
/**
* @constructor
*/
init: function (parent, options, media) {
options = options || {};
this._super(parent, Object.assign({}, {
title: _t("Change media description and tooltip")
}, options));
this.media = media;
var allEscQuots = /&quot;/g;
this.alt = ($(this.media).attr('alt') || "").replace(allEscQuots, '"');
var title = $(this.media).attr('title') || $(this.media).data('original-title') || "";
this.tag_title = (title).replace(allEscQuots, '"');
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
save: function () {
var alt = this.$('#alt').val();
var title = this.$('#title').val();
var allNonEscQuots = /"/g;
$(this.media).attr('alt', alt ? alt.replace(allNonEscQuots, "&quot;") : null)
.attr('title', title ? title.replace(allNonEscQuots, "&quot;") : null);
$(this.media).trigger('content_changed');
this.final_data = this.media;
return this._super.apply(this, arguments);
},
});
/** @odoo-module **/
export default AltDialog;
import { Component, useRef } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
export class AltDialog extends Component {
static components = { Dialog };
static props = {
confirm: Function,
close: Function,
alt: String,
tag_title: String,
};
static template = 'web_edior.AltDialog';
altRef = useRef("alt");
tagTitleRef = useRef("tag_title");
setup() {
this.isConfirmedOrCancelled = false; // ensures we do not confirm and/or cancel twice
}
async _cancel() {
if (this.isConfirmedOrCancelled) {
return;
}
this.isConfirmedOrCancelled = true;
this.props.close();
}
async _confirm() {
if (this.isConfirmedOrCancelled) {
return;
}
this.isConfirmedOrCancelled = true;
try {
const allNonEscQuots = /"/g;
const alt = this.altRef.el.value.replace(allNonEscQuots, "&quot;");
const title = this.tagTitleRef.el.value.replace(allNonEscQuots, "&quot;");
await this.props.confirm(alt, title);
} catch (e) {
this.props.close();
throw e;
}
this.props.close();
}
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_edior.AltDialog" owl="1">
<Dialog size="'xl'" title="'Change media description and tooltip'">
<form action="#">
<div class="mb-3 row">
<label class="col-md-3 col-form-label" for="alt"
title="'Alt tag' specifies an alternate text for an image, if the image cannot be displayed (slow connection, missing image, screen reader ...).">
Description <small>(ALT Tag)</small>
</label>
<div class="col-md-8">
<input class="form-control" t-ref="alt" required="required" t-att-value="props.alt" type="text"/>
</div>
</div>
<div class="mb-3 row">
<label class="col-md-3 col-form-label" for="title"
title="'Title tag' is shown as a tooltip when you hover the picture.">
Tooltip <small>(TITLE Tag)</small>
</label>
<div class="col-md-8">
<input class="form-control" t-ref="tag_title" required="required" t-att-value="props.tag_title" type="text"/>
</div>
</div>
</form>
<t t-set-slot="footer" owl="1">
<button class="btn btn-primary" t-on-click="_confirm">Save</button>
<button class="btn btn-secondary" t-on-click="_cancel">Discard</button>
</t>
</Dialog>
</t>
</templates>
File diff suppressed because it is too large Load Diff
@@ -1,113 +1,83 @@
/** @odoo-module alias=wysiwyg.widgets.ImageCropWidget **/
/** @odoo-module **/
import core from "web.core";
import Widget from "web.Widget";
import {applyModifications, cropperDataFields, activateCropper, loadImage, loadImageInfo} from "web_editor.image_processing";
import { _t } from "@web/core/l10n/translation";
import {
Component,
useRef,
useState,
onMounted,
onWillDestroy,
onWillUpdateProps,
} from "@odoo/owl";
const _t = core._t;
export class ImageCrop extends Component {
static template = 'web_editor.ImageCrop';
static props = {
rpc: Function,
showCount: { type: Number, optional: true },
activeOnStart: { type: Boolean, optional: true },
media: { optional: true },
mimetype: { type: String, optional: true },
};
static defaultProps = {
activeOnStart: false,
showCount: 0,
};
aspectRatios = {
"0/0": {label: _t("Flexible"), value: 0},
"16/9": {label: "16:9", value: 16 / 9},
"4/3": {label: "4:3", value: 4 / 3},
"1/1": {label: "1:1", value: 1},
"2/3": {label: "2:3", value: 2 / 3},
};
state = useState({
active: false,
});
const ImageCropWidget = Widget.extend({
template: ['wysiwyg.widgets.crop'],
events: {
'click.crop_options [data-action]': '_onCropOptionClick',
// zoom event is triggered by the cropperjs library when the user zooms.
'zoom': '_onCropZoom',
},
elRef = useRef('el');
_cropperClosed = true;
/**
* @constructor
*/
init(parent, media, options = {}) {
this._super(...arguments);
this.media = media;
this.$media = $(media);
// Needed for editors in iframes.
this.document = media.ownerDocument;
// key: ratio identifier, label: displayed to user, value: used by cropper lib
this.aspectRatios = {
"0/0": {label: _t("Flexible"), value: 0},
"16/9": {label: "16:9", value: 16 / 9},
"4/3": {label: "4:3", value: 4 / 3},
"1/1": {label: "1:1", value: 1},
"2/3": {label: "2:3", value: 2 / 3},
};
const src = this.media.getAttribute('src');
const data = Object.assign({}, media.dataset);
this.initialSrc = src;
this.aspectRatio = data.aspectRatio || "0/0";
const mimetype = data.mimetype ||
src.endsWith('.png') ? 'image/png' :
src.endsWith('.webp') ? 'image/webp' :
'image/jpeg';
this.mimetype = options.mimetype || mimetype;
},
/**
* @override
*/
async willStart() {
await this._super.apply(this, arguments);
await loadImageInfo(this.media, this._rpc.bind(this));
const isIllustration = /^\/web_editor\/shape\/illustration\//.test(this.media.dataset.originalSrc);
if (this.media.dataset.originalSrc && !isIllustration) {
this.originalSrc = this.media.dataset.originalSrc;
this.originalId = this.media.dataset.originalId;
return;
}
// Couldn't find an attachment: not croppable.
this.uncroppable = true;
},
/**
* @override
*/
async start() {
if (this.uncroppable) {
this.displayNotification({
type: 'warning',
title: _t("This image is an external image"),
message: _t("This type of image is not supported for cropping.<br/>If you want to crop it, please first download it from the original source and upload it in Odoo."),
});
return this.destroy();
}
const _super = this._super.bind(this);
const $cropperWrapper = this.$('.o_we_cropper_wrapper');
setup() {
// This promise is resolved when the component is mounted. It is
// required by a legacy mechanism to wait for the component to be
// mounted. See `ImageTools.resetCrop`.
this.mountedPromise = new Promise((resolve) => {
this.mountedResolve = resolve;
});
onMounted(async () => {
const $el = $(this.elRef.el);
this.$ = $el.find.bind($el);
this.$('[data-action]').on('click', this._onCropOptionClick.bind(this));
$el.on('zoom', this._onCropZoom.bind(this));
if (this.props.activeOnStart) {
this.state.active = true;
await this._show(this.props);
}
this.mountedResolve();
});
onWillUpdateProps((newProps) => {
if (newProps.showCount !== this.props.showCount) {
this.state.active = true;
}
return this._show(newProps);
});
onWillDestroy(() => {
this._closeCropper();
});
}
// Replacing the src with the original's so that the layout is correct.
await loadImage(this.originalSrc, this.media);
this.$cropperImage = this.$('.o_we_cropper_img');
const cropperImage = this.$cropperImage[0];
[cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px'];
// Overlaying the cropper image over the real image
const offset = this.$media.offset();
offset.left += parseInt(this.$media.css('padding-left'));
offset.top += parseInt(this.$media.css('padding-right'));
$cropperWrapper.offset(offset);
await loadImage(this.originalSrc, cropperImage);
await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset);
this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
// We use capture so that the handler is called before other editor handlers
// like save, such that we can restore the src before a save.
this.document.addEventListener('mousedown', this._onDocumentMousedown, {capture: true});
return _super(...arguments);
},
/**
* @override
*/
destroy() {
_closeCropper() {
if (this._cropperClosed) return;
this._cropperClosed = true;
if (this.$cropperImage) {
this.$cropperImage.cropper('destroy');
this.document.removeEventListener('mousedown', this._onDocumentMousedown, {capture: true});
}
this.media.setAttribute('src', this.initialSrc);
this.$media.trigger('image_cropper_destroyed');
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
this.state.active = false;
}
/**
* Resets the crop
@@ -121,12 +91,80 @@ const ImageCropWidget = Widget.extend({
}
await this._save(false);
}
},
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
async _show(props) {
if (!props.media || !this.state.active) {
return;
}
this._cropperClosed = false;
this.media = props.media;
this.$media = $(this.media);
// Needed for editors in iframes.
this.document = this.media.ownerDocument;
// key: ratio identifier, label: displayed to user, value: used by cropper lib
const src = this.media.getAttribute('src');
const data = {...this.media.dataset};
this.initialSrc = src;
this.aspectRatio = data.aspectRatio || "0/0";
const mimetype = data.mimetype ||
src.endsWith('.png') ? 'image/png' :
src.endsWith('.webp') ? 'image/webp' :
'image/jpeg';
this.mimetype = this.props.mimetype || mimetype;
await loadImageInfo(this.media, this.props.rpc);
const isIllustration = /^\/web_editor\/shape\/illustration\//.test(this.media.dataset.originalSrc);
this.uncroppable = false;
if (this.media.dataset.originalSrc && !isIllustration) {
this.originalSrc = this.media.dataset.originalSrc;
this.originalId = this.media.dataset.originalId;
} else {
// Couldn't find an attachment: not croppable.
this.uncroppable = true;
}
if (this.uncroppable) {
this.displayNotification({
type: 'warning',
title: _t("This image is an external image"),
message: _t("This type of image is not supported for cropping.<br/>If you want to crop it, please first download it from the original source and upload it in Odoo."),
});
return this._closeCropper();
}
const $cropperWrapper = this.$('.o_we_cropper_wrapper');
// Replacing the src with the original's so that the layout is correct.
await loadImage(this.originalSrc, this.media);
this.$cropperImage = this.$('.o_we_cropper_img');
const cropperImage = this.$cropperImage[0];
[cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px'];
// Overlaying the cropper image over the real image
const offset = this.$media.offset();
offset.left += parseInt(this.$media.css('padding-left'));
offset.top += parseInt(this.$media.css('padding-right'));
$cropperWrapper[0].style.left = `${offset.left}px`;
$cropperWrapper[0].style.top = `${offset.top}px`;
await loadImage(this.originalSrc, cropperImage);
// We need to remove the d-none class for the cropper library to work.
this.elRef.el.classList.remove('d-none');
await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset);
this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
// We use capture so that the handler is called before other editor handlers
// like save, such that we can restore the src before a save.
this.document.addEventListener('mousedown', this._onDocumentMousedown, {capture: true});
}
/**
* Updates the DOM image with cropped data and associates required
* information for a potential future save (where required cropped data
@@ -150,8 +188,8 @@ const ImageCropWidget = Widget.extend({
this.initialSrc = await applyModifications(this.media, {forceModification: true, mimetype: this.mimetype});
this.media.classList.toggle('o_we_image_cropped', cropped);
this.$media.trigger('image_cropped');
this.destroy();
},
this._closeCropper();
}
/**
* Returns an attribute's value for saving.
*
@@ -162,7 +200,7 @@ const ImageCropWidget = Widget.extend({
return this.$cropperImage.cropper('getData')[attr];
}
return this[attr];
},
}
/**
* Resets the crop box to prevent it going outside the image.
*
@@ -171,7 +209,7 @@ const ImageCropWidget = Widget.extend({
_resetCropBox() {
this.$cropperImage.cropper('clear');
this.$cropperImage.cropper('crop');
},
}
//--------------------------------------------------------------------------
// Handlers
@@ -206,9 +244,9 @@ const ImageCropWidget = Widget.extend({
case 'apply':
return this._save();
case 'discard':
return this.destroy();
return this._closeCropper();
}
},
}
/**
* Discards crop if the user clicks outside of the widget.
*
@@ -217,9 +255,9 @@ const ImageCropWidget = Widget.extend({
*/
_onDocumentMousedown(ev) {
if (document.body.contains(ev.target) && this.$(ev.target).length === 0) {
return this.destroy();
return this._closeCropper();
}
},
}
/**
* Resets the cropbox on zoom to prevent crop box overflowing.
*
@@ -229,7 +267,5 @@ const ImageCropWidget = Widget.extend({
// Wait for the zoom event to be fully processed before reseting.
await new Promise(res => setTimeout(res, 0));
this._resetCropBox();
},
});
export default ImageCropWidget;
}
}
@@ -1,188 +1,107 @@
/** @odoo-module alias=wysiwyg.widgets.Link **/
import core from "web.core";
import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor";
import Widget from "web.Widget";
import {isColorGradient} from "web_editor.utils";
import {
Component,
onWillStart,
onMounted,
onWillUpdateProps,
onWillDestroy,
useState,
useRef,
} from "@odoo/owl";
const getDeepRange = OdooEditorLib.getDeepRange;
const getInSelection = OdooEditorLib.getInSelection;
const EMAIL_REGEX = OdooEditorLib.EMAIL_REGEX;
const _t = core._t;
import { _t } from "@web/core/l10n/translation";
/**
* Allows to customize link content and style.
*/
const Link = Widget.extend({
events: {
'input': '_onAnyChange',
'change': '_onAnyChange',
'input input[name="url"]': '__onURLInput',
'change input[name="url"]': '_onURLInputChange',
},
export class Link extends Component {
static props = {
editable: true,
link: true,
needLabel: { type: Boolean, optional: true },
forceNewWindow: { type: Boolean, optional: true },
initialIsNewWindow: { type: Boolean, optional: true },
shouldFocusUrl: { type: Boolean, optional: true },
};
static defaultProps = {
needLabel: true,
forceNewWindow: false,
initialIsNewWindow: false,
shouldFocusUrl: false,
}
linkComponentWrapperRef = useRef("linkComponentWrapper");
colorsData = [
{type: '', label: _t("Link"), btnPreview: 'link'},
{type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
{type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'},
{type: 'custom', label: _t("Custom"), btnPreview: 'custom'},
// Note: by compatibility the dialog should be able to remove old
// colors that were suggested like the BS status colors or the
// alpha -> epsilon classes. This is currently done by removing
// all btn-* classes anyway.
];
setup() {
this.state = useState({});
/**
* @constructor
* @param {Boolean} data.isButton - whether if the target is a button element.
*/
init: function (parent, options, editable, data, $button, link) {
this.options = options || {};
this._super(parent, Object.assign({
title: _t("Link to"),
}, this.options));
this.data = data || {};
this.isButton = this.data.isButton;
this.$button = $button;
this.noFocusUrl = this.options.noFocusUrl;
this.data.className = this.data.className || "";
this.data.iniClassName = this.data.iniClassName || "";
this.needLabel = this.data.needLabel || false;
// Using explicit type 'link' to preserve style when the target is <button class="...btn-link"/>.
this.colorsData = [
{type: this.isButton ? 'link' : '', label: _t("Link"), btnPreview: 'link'},
{type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
{type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'},
{type: 'custom', label: _t("Custom"), btnPreview: 'custom'},
// Note: by compatibility the dialog should be able to remove old
// colors that were suggested like the BS status colors or the
// alpha -> epsilon classes. This is currently done by removing
// all btn-* classes anyway.
];
// The classes in the following array should not be in editable areas
// but as there are still some (e.g. in the "newsletter block" snippet)
// we make sure the options system works with them.
this.toleratedClasses = ['btn-link', 'btn-success'];
this.editable = editable;
this.$editable = $(editable);
if (link) {
const range = document.createRange();
range.selectNodeContents(link);
this.data.range = range;
this.$link = $(link);
this.linkEl = link;
}
if (this.data.range) {
this.$link = this.$link || $(OdooEditorLib.getInSelection(this.editable.ownerDocument, 'a'));
this.linkEl = this.$link[0];
this.data.iniClassName = this.$link.attr('class') || '';
this.colorCombinationClass = false;
let $node = this.$link;
while ($node.length && !$node.is('body')) {
const className = $node.attr('class') || '';
const m = className.match(/\b(o_cc\d+)\b/g);
if (m) {
this.colorCombinationClass = m[0];
break;
}
$node = $node.parent();
onWillStart(() => this._updateState(this.props));
let started = false;
onMounted(async () => {
if (started) {
return;
}
const linkNode = this.$link[0] || this.data.range.cloneContents();
const linkText = linkNode.innerText;
this.data.content = linkText.replace(/[ \t\r\n]+/g, ' ');
this.data.originalText = this.data.content;
if (linkNode instanceof DocumentFragment) {
this.data.originalHTML = $('<fakeEl>').append(linkNode).html();
} else {
this.data.originalHTML = linkNode.innerHTML;
started = true;
if (!this.linkComponentWrapperRef.el) {
// There is legacy code that can trigger the instantiation of the
// link tool when it's parent component (the toolbar) is not in the
// dom. If the 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.linkComponentWrapperRef.el) {
observer.disconnect();
resolve();
}
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
this.data.url = this.$link.attr('href') || '';
} else {
this.data.content = this.data.content ? this.data.content.replace(/[ \t\r\n]+/g, ' ') : '';
}
this.$el = $(this.linkComponentWrapperRef.el);
if (!this.data.url) {
const urls = this.data.content.match(OdooEditorLib.URL_REGEX_WITH_INFOS);
if (urls) {
this.data.url = urls[0];
}
}
this.$el.find('input, select').on('input', this._onAnyChange.bind(this));
this.$el.find('input, select').on('change', this._onAnyChange.bind(this));
this.$el.find('[name="url"]').on('input', this.__onURLInput.bind(this));
this.$el.find('[name="url"]').on('change', this._onURLInputChange.bind(this));
if (this.linkEl) {
this.data.isNewWindow = this.data.isNewWindow || this.linkEl.target === '_blank';
}
const classesToKeep = [
'text-wrap', 'text-nowrap', 'text-start', 'text-center', 'text-end',
'text-truncate',
];
const keptClasses = this.data.iniClassName.split(' ').filter(className => classesToKeep.includes(className));
const allBtnColorPrefixes = /(^|\s+)(bg|text|border)(-[a-z0-9_-]*)?/gi;
const allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
const allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
this.data.className = this.data.iniClassName
.replace(allBtnColorPrefixes, ' ')
.replace(allBtnClassSuffixes, ' ')
.replace(allBtnShapes, ' ');
this.data.className += ' ' + keptClasses.join(' ');
// 'o_submit' class will force anchor to be handled as a button in linkdialog.
if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) {
this.isButton = true;
}
this.renderingPromise = new Promise(resolve => this._renderingResolver = resolve);
},
await this.start();
});
onWillUpdateProps((newProps) => {
this._updateState(newProps);
this.state.url = newProps.link.getAttribute('href') || '';
this._setUrl({ shouldFocus: newProps.shouldFocusUrl });
});
onWillDestroy(() => {
this.destroy();
});
}
/**
* @override
*/
start: async function () {
for (const option of this._getLinkOptions()) {
const $option = $(option);
const value = $option.is('input') ? $option.val() : $option.data('value') || option.getAttribute('value');
let active = false;
if (value) {
const subValues = value.split(',');
let subActive = true;
for (let subValue of subValues) {
const classPrefix = new RegExp('(^|btn-| |btn-outline-|btn-fill-)' + subValue);
subActive = subActive && classPrefix.test(this.data.iniClassName);
}
active = subActive;
} else {
active = !this.data.iniClassName
|| this.toleratedClasses.some(val => this.data.iniClassName.split(' ').includes(val))
|| !this.data.iniClassName.includes('btn-');
}
this._setSelectOption($option, active);
}
const _super = this._super.bind(this);
async start() {
this._setSelectOptionFromLink();
this._updateOptionsUI();
if (this.data.url) {
var match = /mailto:(.+)/.exec(this.data.url);
this.$('input[name="url"]').val(match ? match[1] : this.data.url);
this._onURLInput();
this._savedURLInputOnDestroy = false;
}
if (!this.noFocusUrl) {
this.focusUrl();
}
return _super(...arguments);
},
/**
* @private
*/
async _widgetRenderAndInsert() {
const res = await this._super(...arguments);
// TODO find a better solution than this during the upcoming refactoring
// of the link tools / link dialog.
if (this._renderingResolver) {
this._renderingResolver();
}
return res;
},
this._setUrl({ shouldFocus: this.props.shouldFocusUrl });
}
/**
* @override
*/
@@ -190,8 +109,7 @@ const Link = Widget.extend({
if (this._savedURLInputOnDestroy) {
this._adaptPreview();
}
this._super(...arguments);
},
}
//--------------------------------------------------------------------------
// Public
@@ -202,7 +120,7 @@ const Link = Widget.extend({
*
* @param {object} data
*/
applyLinkToDom: function (data) {
applyLinkToDom(data) {
// Some mass mailing template use <a class="btn btn-link"> instead of just a simple <a>.
// And we need to keep the classes because the a.btn.btn-link have some special css rules.
// Same thing for the "btn-success" class, this class cannot be added
@@ -210,7 +128,7 @@ const Link = Widget.extend({
// it exists in a template (e.g. "Newsletter Block" snippet).
if (!data.classes.split(' ').includes('btn')) {
for (const linkClass of this.toleratedClasses) {
if (this.data.iniClassName && this.data.iniClassName.split(' ').includes(linkClass)) {
if (this.state.iniClassName && this.state.iniClassName.split(' ').includes(linkClass)) {
data.classes += " btn " + linkClass;
}
}
@@ -232,7 +150,7 @@ const Link = Widget.extend({
this.$link.css('border-style', '');
this.$link.css('border-color', '');
}
const attrs = Object.assign({}, this.data.oldAttributes, {
const attrs = Object.assign({}, this.state.oldAttributes, {
href: data.url,
target: data.isNewWindow ? '_blank' : '',
});
@@ -249,43 +167,69 @@ const Link = Widget.extend({
this.$link[0].removeAttribute('target');
}
this._updateLinkContent(this.$link, data);
},
}
/**
* Focuses the url input.
*/
focusUrl() {
const urlInput = this.el.querySelector('input[name="url"]');
const urlInput = this.linkComponentWrapperRef.el.querySelector('input[name="url"]');
urlInput.focus();
urlInput.select();
},
/**
* Return the link element to edit. Create one from selection if none was
* present in selection.
*
* @param {Node} [options.containerNode]
* @param {Node} [options.startNode]
* @returns {Object}
*/
getOrCreateLink (options) {
Link.getOrCreateLink(options);
},
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_setUrl({ shouldFocus } = {}) {
if (this.state.url) {
var match = /mailto:(.+)/.exec(this.state.url);
this.$el.find('input[name="url"]').val(match ? match[1] : this.state.url);
this._onURLInput();
this._savedURLInputOnDestroy = false;
}
if (shouldFocus) {
this.focusUrl();
}
}
/**
* @private
*/
_setSelectOptionFromLink() {
for (const option of this._getLinkOptions()) {
const $option = $(option);
const value = $option.is('input') ? $option.val() : $option.data('value') || option.getAttribute('value');
let active = false;
if (value) {
const subValues = value.split(',');
let subActive = true;
for (let subValue of subValues) {
const classPrefix = new RegExp('(^|btn-| |btn-outline-|btn-fill-)' + subValue);
subActive = subActive && classPrefix.test(this.state.iniClassName);
}
active = subActive;
} else {
active = !this.state.iniClassName
|| this.toleratedClasses.some(val => this.state.iniClassName.split(' ').includes(val))
|| !this.state.iniClassName.includes('btn-');
}
this._setSelectOption($option, active);
}
}
/**
* Abstract method: adapt the link to changes.
*
* @abstract
* @private
*/
_adaptPreview: function () {},
_adaptPreview() {}
/**
* @private
*/
_correctLink: function (url) {
_correctLink(url) {
if (url.indexOf('mailto:') === 0 || url.indexOf('tel:') === 0) {
url = url.replace(/^tel:([0-9]+)$/, 'tel://$1');
} else if (url.indexOf('@') !== -1 && url.indexOf(':') === -1) {
@@ -295,7 +239,7 @@ const Link = Widget.extend({
url = 'http://' + url;
}
return url;
},
}
/**
* Abstract method: return true if the URL should be stripped of its domain.
*
@@ -303,19 +247,19 @@ const Link = Widget.extend({
* @private
* @returns {boolean}
*/
_doStripDomain: function () {},
_doStripDomain() {}
/**
* Get the link's data (url, content and styles).
*
* @private
* @returns {Object} {content: String, url: String, classes: String, isNewWindow: Boolean}
*/
_getData: function () {
var $url = this.$('input[name="url"]');
_getData() {
var $url = this.$el.find('input[name="url"]');
var url = $url.val();
var content = this.$('input[name="label"]').val() || url;
var content = this.$el.find('input[name="label"]').val() || url;
if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
if (!this.state.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
return null;
}
@@ -331,7 +275,7 @@ const Link = Widget.extend({
const shapes = shape ? shape.split(',') : [];
const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : '';
const shapeClasses = shapes.slice(style ? 1 : 0).join(' ');
const classes = (this.data.className || '') +
const classes = (this.state.className || '') +
(type ? (` btn btn-${style}${type}`) : '') +
(type === 'custom' ? customClasses : '') +
(type && shapeClasses ? (` ${shapeClasses}`) : '') +
@@ -357,11 +301,11 @@ const Link = Widget.extend({
customBorder: customBorder,
customBorderWidth: customBorderWidth,
customBorderStyle: customBorderStyle,
oldAttributes: this.data.oldAttributes,
oldAttributes: this.state.oldAttributes,
isNewWindow: isNewWindow,
doStripDomain: doStripDomain,
};
},
}
/**
* Return a list of all the descendants of a given element.
*
@@ -369,14 +313,14 @@ const Link = Widget.extend({
* @param {Node} rootNode
* @returns {Node[]}
*/
_getDescendants: function (rootNode) {
_getDescendants(rootNode) {
const nodes = [];
for (const node of rootNode.childNodes) {
nodes.push(node);
nodes.push(...this._getDescendants(node));
}
return nodes;
},
}
/**
* Abstract method: return a JQuery object containing the UI elements
* holding the "Open in new window" option's row of the link.
@@ -385,7 +329,7 @@ const Link = Widget.extend({
* @private
* @returns {JQuery}
*/
_getIsNewWindowFormRow() {},
_getIsNewWindowFormRow() {}
/**
* Abstract method: return a JQuery object containing the UI elements
* holding the styling options of the link (eg: color, size, shape).
@@ -394,7 +338,7 @@ const Link = Widget.extend({
* @private
* @returns {JQuery}
*/
_getLinkOptions: function () {},
_getLinkOptions() {}
/**
* Abstract method: return the shape(s) to apply to the link (eg:
* "outline", "rounded-circle", "outline,rounded-circle").
@@ -403,7 +347,7 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkShape: function () {},
_getLinkShape() {}
/**
* Abstract method: return the size to apply to the link (eg:
* "sm", "lg").
@@ -411,7 +355,7 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkSize: function () {},
_getLinkSize() {}
/**
* Abstract method: return the type to apply to the link (eg:
* "primary", "secondary").
@@ -419,7 +363,7 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkType: function () {},
_getLinkType() {}
/**
* Returns the custom text color for custom type.
*
@@ -427,7 +371,7 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkCustomTextColor: function () {},
_getLinkCustomTextColor() {}
/**
* Returns the custom border color for custom type.
*
@@ -435,7 +379,7 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkCustomBorder: function () {},
_getLinkCustomBorder() {}
/**
* Returns the custom border width for custom type.
*
@@ -443,7 +387,7 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkCustomBorderWidth: function () {},
_getLinkCustomBorderWidth() {}
/**
* Returns the custom border style for custom type.
*
@@ -451,7 +395,7 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkCustomBorderStyle: function () {},
_getLinkCustomBorderStyle() {}
/**
* Returns the custom fill color for custom type.
*
@@ -459,7 +403,7 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkCustomFill: function () {},
_getLinkCustomFill() {}
/**
* Returns the custom text, fill and border color classes for custom type.
*
@@ -467,11 +411,11 @@ const Link = Widget.extend({
* @private
* @returns {string}
*/
_getLinkCustomClasses: function () {},
_getLinkCustomClasses() {}
/**
* @private
*/
_isFromAnotherHostName: function (url) {
_isFromAnotherHostName(url) {
if (url.includes(window.location.hostname)) {
return false;
}
@@ -482,7 +426,7 @@ const Link = Widget.extend({
} catch {
return true;
}
},
}
/**
* Abstract method: return true if the link should open in a new window.
*
@@ -490,7 +434,7 @@ const Link = Widget.extend({
* @private
* @returns {boolean}
*/
_isNewWindow: function (url) {},
_isNewWindow(url) {}
/**
* Abstract method: mark one or several options as active or inactive.
*
@@ -499,7 +443,7 @@ const Link = Widget.extend({
* @param {JQuery} $option
* @param {boolean} [active]
*/
_setSelectOption: function ($option, active) {},
_setSelectOption($option, active) {}
/**
* Update the link content.
*
@@ -509,9 +453,9 @@ const Link = Widget.extend({
* @param {boolean} force
*/
_updateLinkContent($link, linkInfos, { force = false } = {}) {
if (force || (this.needLabel && (linkInfos.content !== this.data.originalText || linkInfos.url !== this.data.url))) {
if (linkInfos.content === this.data.originalText) {
$link.html(this.data.originalHTML);
if (force || (this.props.needLabel && (linkInfos.content !== this.state.originalText || linkInfos.url !== this.state.url))) {
if (linkInfos.content === this.state.originalText) {
$link.html(this.state.originalHTML);
} else if (linkInfos.content && linkInfos.content.length) {
let contentWrapperEl = $link[0];
// Update the first child element that has the same inner text
@@ -526,12 +470,98 @@ const Link = Widget.extend({
$link.text(linkInfos.url);
}
}
},
}
/**
* @abstract
* @private
*/
_updateOptionsUI: function () {},
_updateOptionsUI() {}
/**
* Update the state.
*
* @private
*/
async _updateState(props) {
this.initialNewWindow = props.initialIsNewWindow;
this.state.className = "";
this.state.iniClassName = "";
// The classes in the following array should not be in editable areas
// but as there are still some (e.g. in the "newsletter block" snippet)
// we make sure the options system works with them.
this.toleratedClasses = ['btn-link', 'btn-success'];
this.editable = props.editable;
this.$editable = $(this.editable);
if (props.link) {
const range = document.createRange();
range.selectNodeContents(props.link);
this.state.range = range;
this.$link = $(props.link);
this.linkEl = props.link;
}
if (this.state.range) {
this.$link = this.$link || $(OdooEditorLib.getInSelection(this.editable.ownerDocument, 'a'));
this.linkEl = this.$link[0];
this.state.iniClassName = this.$link.attr('class') || '';
this.colorCombinationClass = false;
let $node = this.$link;
while ($node.length && !$node.is('body')) {
const className = $node.attr('class') || '';
const m = className.match(/\b(o_cc\d+)\b/g);
if (m) {
this.colorCombinationClass = m[0];
break;
}
$node = $node.parent();
}
const linkNode = this.$link[0] || this.state.range.cloneContents();
const linkText = linkNode.innerText;
this.state.initialContent = linkText.replace(/[ \t\r\n]+/g, ' ');
this.state.originalText = this.state.initialContent;
if (linkNode instanceof DocumentFragment) {
this.state.originalHTML = $('<fakeEl>').append(linkNode).html();
} else {
this.state.originalHTML = linkNode.innerHTML;
}
this.state.url = this.$link.attr('href') || '';
} else {
this.state.initialContent = this.state.initialContent ? this.state.initialContent.replace(/[ \t\r\n]+/g, ' ') : '';
}
if (!this.state.url) {
const urls = this.state.initialContent.match(OdooEditorLib.URL_REGEX_WITH_INFOS);
if (urls) {
this.state.url = urls[0];
}
}
if (this.linkEl) {
this.initialNewWindow = this.initialNewWindow || this.linkEl.target === '_blank';
}
const classesToKeep = [
'text-wrap', 'text-nowrap', 'text-start', 'text-center', 'text-end',
'text-truncate',
];
const keptClasses = this.state.iniClassName.split(' ').filter(className => classesToKeep.includes(className));
const allBtnColorPrefixes = /(^|\s+)(bg|text|border)(-[a-z0-9_-]*)?/gi;
const allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
const allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
this.state.className = this.state.iniClassName
.replace(allBtnColorPrefixes, ' ')
.replace(allBtnClassSuffixes, ' ')
.replace(allBtnShapes, ' ');
this.state.className += ' ' + keptClasses.join(' ');
// 'o_submit' class will force anchor to be handled as a button in linkdialog.
if (/(?:s_website_form_send|o_submit)/.test(this.state.className)) {
this.state.isButton = true;
}
}
//--------------------------------------------------------------------------
// Handlers
@@ -540,11 +570,11 @@ const Link = Widget.extend({
/**
* @private
*/
_onAnyChange: function (e) {
_onAnyChange(e) {
if (!e.target.closest('input[type="text"]')) {
this._adaptPreview();
}
},
}
/**
* @todo Adapt in master: in stable _onURLInput was both used as an event
* handler responding to url input events + a private method called at the
@@ -559,28 +589,28 @@ const Link = Widget.extend({
*
* @private
*/
__onURLInput: function () {
__onURLInput() {
this._onURLInput(...arguments);
},
}
/**
* @private
*/
_onURLInput: function () {
_onURLInput() {
this._savedURLInputOnDestroy = true;
var $linkUrlInput = this.$('#o_link_dialog_url_input');
var $linkUrlInput = this.$el.find('#o_link_dialog_url_input');
let value = $linkUrlInput.val();
let isLink = !EMAIL_REGEX.test(value);
this._getIsNewWindowFormRow().toggleClass('d-none', !isLink);
this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
},
this.$el.find('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
}
/**
* @private
*/
_onURLInputChange: function () {
_onURLInputChange() {
this._adaptPreview();
this._savedURLInputOnDestroy = false;
},
});
}
}
/**
* Return the link element to edit. Create one from selection if none was
@@ -590,8 +620,7 @@ const Link = Widget.extend({
* @param {Node} [options.startNode]
* @returns {Object}
*/
Link.getOrCreateLink = ({ containerNode, startNode } = {}) => {
export function getOrCreateLink({ containerNode, startNode } = {}) {
if (startNode) {
if ($(startNode).is('a')) {
return { link: startNode, needLabel: false };
@@ -634,5 +663,3 @@ Link.getOrCreateLink = ({ containerNode, startNode } = {}) => {
}
return { link, needLabel };
};
export default Link;
@@ -1,28 +1,42 @@
/** @odoo-module alias=wysiwyg.widgets.LinkDialog **/
/** @odoo-module **/
import Dialog from "wysiwyg.widgets.Dialog";
import Link from "wysiwyg.widgets.Link";
import { onMounted, useRef } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { Link } from "./link";
export class LinkDialog extends Link {
static components = { Dialog };
static template = 'web_editor.LinkDialog';
static props = {
...Link.props,
focusField: { type: String, optional: true },
close: { type: Function },
onSave: { type: Function },
};
inputTextRef = useRef('inputText');
inputUrlRef = useRef('inputUrl');
// This widget is there only to extend Link and be instantiated by LinkDialog.
const _DialogLinkWidget = Link.extend({
template: 'wysiwyg.widgets.link',
events: Object.assign({}, Link.prototype.events || {}, {
'change [name="link_style_color"]': '_onTypeChange',
}),
setup() {
super.setup();
onMounted(() => {
this.$el.find('[name="link_style_color"]').on('change', this._onTypeChange.bind(this));
const el = this.props.focusField === 'url' ? this.inputUrlRef.el : this.inputTextRef.el;
el.focus();
});
}
/**
* @override
*/
start: function () {
this.buttonOptsCollapseEl = this.el.querySelector('#o_link_dialog_button_opts_collapse');
this.$styleInputs = this.$('input.link-style');
start() {
super.start();
this.buttonOptsCollapseEl = this.linkComponentWrapperRef.el.querySelector('#o_link_dialog_button_opts_collapse');
this.$styleInputs = this.$el.find('input.link-style');
this.$styleInputs.prop('checked', false).filter('[value=""]').prop('checked', true);
if (this.data.isNewWindow) {
this.$('we-button.o_we_checkbox_wrapper').toggleClass('active', true);
if (this.initialNewWindow) {
this.$el.find('we-button.o_we_checkbox_wrapper').toggleClass('active', true);
}
return this._super.apply(this, arguments);
},
}
//--------------------------------------------------------------------------
// Public
@@ -31,30 +45,28 @@ const _DialogLinkWidget = Link.extend({
/**
* @override
*/
save: function () {
onSave() {
var data = this._getData();
if (data === null) {
var $url = this.$('input[name="url"]');
var $url = this.$el.find('input[name="url"]');
$url.closest('.o_url_input').addClass('o_has_error').find('.form-control, .form-select').addClass('is-invalid');
$url.focus();
return Promise.reject();
return;
}
this.data.content = data.content;
this.data.url = data.url;
var allWhitespace = /\s+/gi;
var allStartAndEndSpace = /^\s+|\s+$/gi;
var allBtnTypes = /(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi;
this.data.classes = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '');
data.classes = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '');
if (data.classes.replace(allBtnTypes, ' ')) {
this.data.style = {
data.style = {
'background-color': '',
'color': '',
};
}
this.data.isNewWindow = data.isNewWindow;
this.final_data = this.data;
return Promise.resolve();
},
data.linkDialog = this;
this.props.close();
this.props.onSave(data);
}
//--------------------------------------------------------------------------
// Private
@@ -63,7 +75,7 @@ const _DialogLinkWidget = Link.extend({
/**
* @override
*/
_adaptPreview: function () {
_adaptPreview() {
var data = this._getData();
if (data === null) {
return;
@@ -74,65 +86,65 @@ const _DialogLinkWidget = Link.extend({
class: `${data.classes.replace(/float-\w+/, '')} o_btn_preview`,
};
const $linkPreview = this.$("#link-preview");
const $linkPreview = this.$el.find("#link-preview");
$linkPreview.attr(attrs);
this._updateLinkContent($linkPreview, data, { force: true });
},
}
/**
* @override
*/
_doStripDomain: function () {
return this.$('#o_link_dialog_url_strip_domain').prop('checked');
},
_doStripDomain() {
return this.$el.find('#o_link_dialog_url_strip_domain').prop('checked');
}
/**
* @override
*/
_getIsNewWindowFormRow() {
return this.$('input[name="is_new_window"]').closest('.row');
},
return this.$el.find('input[name="is_new_window"]').closest('.row');
}
/**
* @override
*/
_getLinkOptions: function () {
_getLinkOptions() {
const options = [
'input[name="link_style_color"]',
'select[name="link_style_size"] > option',
'select[name="link_style_shape"] > option',
];
return this.$(options.join(','));
},
return this.$el.find(options.join(','));
}
/**
* @override
*/
_getLinkShape: function () {
return this.$('select[name="link_style_shape"]').val() || '';
},
_getLinkShape() {
return this.$el.find('select[name="link_style_shape"]').val() || '';
}
/**
* @override
*/
_getLinkSize: function () {
return this.$('select[name="link_style_size"]').val() || '';
},
_getLinkSize() {
return this.$el.find('select[name="link_style_size"]').val() || '';
}
/**
* @override
*/
_getLinkType: function () {
return this.$('input[name="link_style_color"]:checked').val() || '';
},
_getLinkType() {
return this.$el.find('input[name="link_style_color"]:checked').val() || '';
}
/**
* @override
*/
_isNewWindow: function (url) {
if (this.options.forceNewWindow) {
_isNewWindow(url) {
if (this.props.forceNewWindow) {
return this._isFromAnotherHostName(url);
} else {
return this.$('input[name="is_new_window"]').prop('checked');
return this.$el.find('input[name="is_new_window"]').prop('checked');
}
},
}
/**
* @override
*/
_setSelectOption: function ($option, active) {
_setSelectOption($option, active) {
if ($option.is("input")) {
$option.prop("checked", active);
} else if (active) {
@@ -140,14 +152,14 @@ const _DialogLinkWidget = Link.extend({
$option.parent().val($option.val());
$option.attr('selected', 'selected').prop('selected', 'selected');
}
},
}
/**
* @override
*/
_updateOptionsUI: function () {
const el = this.el.querySelector('[name="link_style_color"]:checked');
_updateOptionsUI() {
const el = this.linkComponentWrapperRef.el.querySelector('[name="link_style_color"]:checked');
$(this.buttonOptsCollapseEl).collapse(el && el.value ? 'show' : 'hide');
},
}
//--------------------------------------------------------------------------
// Handlers
@@ -158,56 +170,12 @@ const _DialogLinkWidget = Link.extend({
*/
_onTypeChange() {
this._updateOptionsUI();
},
}
/**
* @override
*/
_onURLInput: function () {
this._super(...arguments);
this.$('#o_link_dialog_url_input').closest('.o_url_input').removeClass('o_has_error').find('.form-control, .form-select').removeClass('is-invalid');
_onURLInput() {
this.$el.find('#o_link_dialog_url_input').closest('.o_url_input').removeClass('o_has_error').find('.form-control, .form-select').removeClass('is-invalid');
this._adaptPreview();
},
});
/**
* Allows to customize link content and style.
*/
const LinkDialog = Dialog.extend({
init: function (parent, ...args) {
this._super(...arguments);
this.linkWidget = this.getLinkWidget(...args);
},
start: async function () {
const res = await this._super(...arguments);
await this.linkWidget.appendTo(this.$el);
return res;
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns an instance of the widget that will be attached to the body of the
* link dialog. One may overwrite this function and return an instance of
* another widget to change the default logic.
* @param {...any} args
*/
getLinkWidget: function (...args) {
return new _DialogLinkWidget(this, ...args);
},
/**
* @override
*/
save: function () {
const _super = this._super.bind(this);
const saveArguments = arguments;
return this.linkWidget.save().then(() => {
this.final_data = this.linkWidget.final_data;
return _super(...saveArguments);
});
},
});
export default LinkDialog;
}
}
@@ -1,41 +1,70 @@
/** @odoo-module **/
import Widget from 'web.Widget';
import {_t} from 'web.core';
import {DropPrevious} from 'web.concurrency';
import { _t } from "@web/core/l10n/translation";
import { ancestors } from '@web_editor/js/common/wysiwyg_utils';
import { KeepLast } from '@web/core/utils/concurrency';
const LinkPopoverWidget = Widget.extend({
template: 'wysiwyg.widgets.link.edit.tooltip',
events: {
'click .o_we_remove_link': '_onRemoveLinkClick',
'click .o_we_edit_link': '_onEditLinkClick',
},
export class LinkPopoverWidget {
static createFor(params) {
const noLinkPopoverClass = ".o_no_link_popover, .carousel-control-prev, .carousel-control-next, .dropdown-toggle";
// Target might already have a popover, eg cart icon in navbar
const alreadyPopover = $(params.target).data('bs.popover');
if (alreadyPopover || $(params.target).is(noLinkPopoverClass) || !!$(params.target).parents(noLinkPopoverClass).length) {
return null;
}
const popoverWidget = new this(params);
params.wysiwyg?.odooEditor.observerUnactive('LinkPopoverWidget');
popoverWidget.start();
params.wysiwyg?.odooEditor.observerActive('LinkPopoverWidget');
return popoverWidget;
};
/**
* @constructor
* @param {Element} target: target Element for which we display a popover
* @param {Wysiwyg} [option.wysiwyg]: The wysiwyg editor
*/
init(parent, target, options) {
this._super(...arguments);
this.options = options;
this.target = target;
this.$target = $(target);
this.container = this.options.container || this.target.ownerDocument.body;
template = `
<div class="d-flex">
<span class="me-2 o_we_preview_favicon"><i class="fa fa-globe"></i><img class="align-baseline d-none"></img></span>
<div class="w-100">
<div class="d-flex">
<a href="#" target="_blank" class="o_we_url_link fw-bold flex-grow-1 text-truncate" title="${_t('Open in a new tab')}"></a>
<a href="#" class="mx-1 o_we_copy_link text-dark" data-bs-toggle="tooltip" data-bs-placement="top" title="${_t('Copy Link')}">
<i class="fa fa-clone"></i>
</a>
<a href="#" class="mx-1 o_we_edit_link text-dark" data-bs-toggle="tooltip" data-bs-placement="top" title="${_t('Edit Link')}">
<i class="fa fa-edit"></i>
</a>
<a href="#" class="ms-1 o_we_remove_link text-dark" data-bs-toggle="tooltip" data-bs-placement="top" title="${_t('Remove Link')}">
<i class="fa fa-chain-broken"></i>
</a>
</div>
<a href="#" target="_blank" class="o_we_full_url mt-1 text-muted d-none" title="${_t('Open in a new tab')}"></a>
</div>
</div>
`;
constructor(params) {
const template = document.createElement('template');
template.innerHTML = this.template;
this.el = template.content.firstElementChild;
this.$el = $(this.el);
this.wysiwyg = params.wysiwyg;
this.target = params.target;
this.notify = params.notify;
this.$target = $(params.target);
this.container = params.container || this.target.ownerDocument.body;
this.href = this.$target.attr('href'); // for template
this._dp = new DropPrevious();
},
this._keepLastPromise = new KeepLast();
}
/**
*
* @override
*/
start() {
this.$urlLink = this.$('.o_we_url_link');
this.$previewFaviconImg = this.$('.o_we_preview_favicon img');
this.$previewFaviconFa = this.$('.o_we_preview_favicon .fa');
this.$copyLink = this.$('.o_we_copy_link');
this.$fullUrl = this.$('.o_we_full_url');
this.$urlLink = this.$el.find('.o_we_url_link');
this.$previewFaviconImg = this.$el.find('.o_we_preview_favicon img');
this.$previewFaviconFa = this.$el.find('.o_we_preview_favicon .fa');
this.$copyLink = this.$el.find('.o_we_copy_link');
this.$fullUrl = this.$el.find('.o_we_full_url');
// Use the right ClipboardJS with respect to the prototype of this.el
// since, starting with Firefox 109, a widget element prototype that is
@@ -45,6 +74,11 @@ const LinkPopoverWidget = Widget.extend({
this.el instanceof HTMLElement
? window.ClipboardJS
: this.el.ownerDocument.defaultView.ClipboardJS;
this.$urlLink.attr('href', this.href);
this.$fullUrl.attr('href', this.href);
this.$el.find(`.o_we_edit_link`).on('click', this._onEditLinkClick.bind(this));
this.$el.find(`.o_we_remove_link`).on('click', this._onRemoveLinkClick.bind(this));
// Copy onclick handler
// ClipboardJS uses "instanceof" to verify the elements passed to its
// constructor. Unfortunately, when the element is within an iframe,
@@ -60,7 +94,7 @@ const LinkPopoverWidget = Widget.extend({
);
clipboard.on('success', () => {
this.$copyLink.tooltip('hide');
this.displayNotification({
this.notify({
type: 'success',
message: _t("Link copied to clipboard."),
});
@@ -68,13 +102,13 @@ const LinkPopoverWidget = Widget.extend({
});
// init tooltips & popovers
this.$('[data-bs-toggle="tooltip"]').tooltip({
this.$el.find('[data-bs-toggle="tooltip"]').tooltip({
delay: 0,
placement: 'bottom',
container: this.container,
});
const tooltips = [];
for (const el of this.$('[data-bs-toggle="tooltip"]').toArray()) {
for (const el of this.$el.find('[data-bs-toggle="tooltip"]').toArray()) {
tooltips.push(Tooltip.getOrCreateInstance(el));
}
let popoverShown = true;
@@ -93,19 +127,19 @@ const LinkPopoverWidget = Widget.extend({
container: this.container,
})
.on('show.bs.popover.link_popover', () => {
this.options.wysiwyg.odooEditor.observerUnactive('show.bs.popover');
this.wysiwyg.odooEditor.observerUnactive('show.bs.popover');
this._loadAsyncLinkPreview();
popoverShown = true;
})
.on('inserted.bs.popover', () => {
this.options.wysiwyg.odooEditor.observerActive('show.bs.popover');
this.wysiwyg.odooEditor.observerActive('show.bs.popover');
})
.on('hide.bs.popover.link_popover', () => {
this.options.wysiwyg.odooEditor.observerUnactive('hide.bs.popover');
this.wysiwyg.odooEditor.observerUnactive('hide.bs.popover');
popoverShown = false;
})
.on('hidden.bs.popover.link_popover', () => {
this.options.wysiwyg.odooEditor.observerActive('hide.bs.popover');
this.wysiwyg.odooEditor.observerActive('hide.bs.popover');
for (const tooltip of tooltips) {
tooltip.hide();
}
@@ -146,12 +180,10 @@ const LinkPopoverWidget = Widget.extend({
}
}
$(document).on('mouseup.link_popover', onClickDocument);
if (document !== this.options.wysiwyg.odooEditor.document) {
$(this.options.wysiwyg.odooEditor.document).on('mouseup.link_popover', onClickDocument);
if (document !== this.wysiwyg.odooEditor.document) {
$(this.wysiwyg.odooEditor.document).on('mouseup.link_popover', onClickDocument);
}
return this._super(...arguments);
},
}
/**
*
* @override
@@ -162,17 +194,16 @@ const LinkPopoverWidget = Widget.extend({
// mode so this should not be a huge problem.
this.$target.off('.link_popover');
$(document).off('.link_popover');
$(this.options.wysiwyg.odooEditor.document).off('.link_popover');
$(this.wysiwyg.odooEditor.document).off('.link_popover');
this.$target.popover('dispose');
return this._super(...arguments);
},
}
/**
* Hide the popover.
*/
hide() {
this.$target.popover('hide');
},
}
//--------------------------------------------------------------------------
// Private
@@ -196,7 +227,7 @@ const LinkPopoverWidget = Widget.extend({
} catch {
// Invalid URL, might happen with editor unsuported protocol. eg type
// `geo:37.786971,-122.399677`, become `http://geo:37.786971,-122.399677`
this.displayNotification({
this.notify({
type: 'danger',
message: _t("This URL is invalid. Preview couldn't be updated."),
});
@@ -221,7 +252,7 @@ const LinkPopoverWidget = Widget.extend({
}).removeClass('d-none');
this.$previewFaviconFa.addClass('d-none');
} else {
await this._dp.add($.get(this.target.href)).then(content => {
await this._keepLastPromise.add($.get(this.target.href)).then(content => {
const parser = new window.DOMParser();
const doc = parser.parseFromString(content, "text/html");
@@ -242,7 +273,7 @@ const LinkPopoverWidget = Widget.extend({
this.$target.popover('update');
});
}
},
}
/**
* Resets the preview elements visibility. Particularly useful when changing
* the link url from an internal to an external one and vice versa.
@@ -255,7 +286,7 @@ const LinkPopoverWidget = Widget.extend({
this.$previewFaviconFa.removeClass('d-none fa-question-circle-o fa-envelope-o fa-phone').addClass('fa-globe');
this.$urlLink.add(this.$fullUrl).text(url || _t('No URL specified')).attr('href', url || null);
this.$fullUrl.addClass('d-none').removeClass('o_we_webkit_box');
},
}
//--------------------------------------------------------------------------
// Handlers
@@ -271,13 +302,13 @@ const LinkPopoverWidget = Widget.extend({
*/
_onEditLinkClick(ev) {
ev.preventDefault();
this.options.wysiwyg.toggleLinkTools({
this.wysiwyg.toggleLinkTools({
forceOpen: true,
link: this.$target[0],
});
ev.stopImmediatePropagation();
this.popover.hide();
},
}
/**
* Removes the link/anchor.
*
@@ -286,29 +317,8 @@ const LinkPopoverWidget = Widget.extend({
*/
_onRemoveLinkClick(ev) {
ev.preventDefault();
this.options.wysiwyg.removeLink();
this.wysiwyg.removeLink();
ev.stopImmediatePropagation();
this.popover.hide();
},
});
LinkPopoverWidget.createFor = async function (parent, targetEl, options) {
const noLinkPopoverClass = ".o_no_link_popover, .carousel-control-prev, .carousel-control-next, .dropdown-toggle";
// Target might already have a popover, eg cart icon in navbar
const alreadyPopover = $(targetEl).data('bs.popover');
if (alreadyPopover || $(targetEl).is(noLinkPopoverClass) || !!$(targetEl).parents(noLinkPopoverClass).length) {
return null;
}
const popoverWidget = new this(parent, targetEl, options);
const wysiwyg = options.wysiwyg;
if (wysiwyg) {
wysiwyg.odooEditor.observerUnactive('LinkPopoverWidget');
}
await popoverWidget.appendTo(targetEl)
if (wysiwyg) {
wysiwyg.odooEditor.observerActive('LinkPopoverWidget');
}
return popoverWidget;
};
export default LinkPopoverWidget;
}
@@ -1,8 +1,7 @@
/** @odoo-module alias=wysiwyg.widgets.LinkTools **/
/** @odoo-module **/
import Link from "wysiwyg.widgets.Link";
import {ColorPaletteWidget} from "web_editor.ColorPalette";
import {ColorpickerWidget} from "web.Colorpicker";
import { Link } from "./link";
import { ColorPalette } from '@web_editor/js/wysiwyg/widgets/color_palette';
import {
computeColorClasses,
getCSSVariableValue,
@@ -10,101 +9,106 @@ import {
getNumericAndUnit,
isColorGradient,
} from "web_editor.utils";
import {
onWillUpdateProps,
useState,
} from "@odoo/owl";
import { normalizeCSSColor } from '@web/core/utils/colors';
/**
* Allows to customize link content and style.
*/
const LinkTools = Link.extend({
template: 'wysiwyg.widgets.linkTools',
events: Object.assign({}, Link.prototype.events, {
'click we-select we-button': '_onPickSelectOption',
'click we-checkbox': '_onClickCheckbox',
'change .link-custom-color-border input': '_onChangeCustomBorderWidth',
'keypress .link-custom-color-border input': '_onKeyPressCustomBorderWidth',
'click we-select [name="link_border_style"] we-button': '_onBorderStyleSelectOption',
'input input[name="label"]': '_onLabelInput',
}),
export class LinkTools extends Link {
static template = 'web_editor.LinkTools';
static props = {
...Link.props,
wysiwyg: { type: Object },
$button: { type: Object },
onColorCombinationClassChange: { type: Function, optional: true },
onPreApplyLink: { type: Function, optional: true },
onPostApplyLink: { type: Function, optional: true },
onDestroy: { type: Function, optional: true },
};
static defaultProps = {
...Link.defaultProps,
onColorCombinationClassChange: () => {},
onPreApplyLink: () => {},
onPostApplyLink: () => {},
onDestroy: () => {},
};
static components = { ColorPalette };
colorpickerProps = useState({
'color': { selectedColor: undefined },
'background-color': { selectedColor: undefined },
'border-color': { selectedColor: undefined },
});
colorpickers = {
'color': { colorNames: null },
'background-color': { colorNames: null },
'border-color': { colorNames: null },
};
setup() {
super.setup(...arguments);
onWillUpdateProps((newProps) => {
this.$link = newProps.link ? $(newProps.link) : this.link;
this._setSelectOptionFromLink();
this._updateOptionsUI();
});
}
/**
* @override
*/
init: function (parent, options, editable, data, $button, link) {
this._link = link;
async _updateState() {
await super._updateState(...arguments);
this._observer = new MutationObserver(async () => {
await this.renderingPromise;
this._updateLabelInput();
});
this._observer.observe(this._link, {subtree: true, childList: true, characterData: true});
this._super(parent, options, editable, data, $button, this._link);
this._observer.observe(this.props.link, {subtree: true, childList: true, characterData: true});
// Keep track of each selected custom color and colorpicker.
this.customColors = {};
this.colorpickers = {};
this.COLORPICKER_CSS_PROPERTIES = ['color', 'background-color', 'border-color'];
this.PREFIXES = {
'color': 'text-',
'background-color': 'bg-',
};
},
}
/**
* @override
*/
willStart: async function () {
const _super = this._super.bind(this);
await Promise.all(this.COLORPICKER_CSS_PROPERTIES.map(cssProperty => this._addColorPicker(cssProperty)));
return _super(...arguments);
},
/**
* @override
*/
start: async function () {
this._addHintClasses();
const ret = await this._super(...arguments);
const link = this.$link[0];
const colorpickerLocations = {
'color': '.link-custom-color-text .dropdown-menu',
'background-color': '.link-custom-color-fill .dropdown-menu',
'border-color': '.link-custom-color-border .o_we_so_color_palette .dropdown-menu',
};
for (const cssProperty of this.COLORPICKER_CSS_PROPERTIES) {
// Colorpickers were created into fragments before any UI or event
// handler of this main widget was built. This just moves those
// colorpickers at their rightful position, synchronously.
const locationEl = this.el.querySelector(colorpickerLocations[cssProperty]);
this.colorpickers[cssProperty].$el.appendTo(locationEl);
}
const customStyleProps = ['color', 'background-color', 'background-image', 'border-width', 'border-style', 'border-color'];
const shapeClasses = ['btn-outline-primary', 'btn-outline-secondary', 'btn-fill-primary', 'btn-fill-secondary', 'rounded-circle', 'flat'];
if (customStyleProps.some(s => link.style[s]) || shapeClasses.some(c => link.classList.contains(c))) {
// Force custom style if style or shape exists on the link.
const customOption = this.el.querySelector('[name="link_style_color"] we-button[data-value="custom"]');
this._setSelectOption($(customOption), true);
this._updateOptionsUI();
}
async start() {
const ret = await super.start(...arguments);
this.$el.find('we-select we-button').on('click', this._onPickSelectOption.bind(this));
this.$el.find('we-checkbox').on('click', this._onClickCheckbox.bind(this));
this.$el.find('.link-custom-color-border input').on('change', this._onChangeCustomBorderWidth.bind(this));
this.$el.find('.link-custom-color-border input').on('keypress', this._onKeyPressCustomBorderWidth.bind(this));
this.$el.find('we-select [name="link_border_style"] we-button').on('click', this._onBorderStyleSelectOption.bind(this));
this.$el.find('input[name="label"]').on('input', this._onLabelInput.bind(this));
this._setSelectOptionFromLink();
this._updateOptionsUI();
return ret;
},
destroy: function () {
if (!this.el) {
return this._super(...arguments);
}
destroy() {
if (!this.$el?.[0]) {
return super.destroy(...arguments);
}
const $contents = this.$link.contents();
if (this.shouldUnlink()) {
if (shouldUnlink(this.$link[0], this.colorCombinationClass)) {
$contents.unwrap();
}
this._observer.disconnect();
this._super(...arguments);
this._removeHintClasses();
},
shouldUnlink: function () {
return !this.$link.attr('href') && !this.colorCombinationClass
},
super.destroy(...arguments);
this.props.onDestroy();
}
applyLinkToDom() {
this._observer.disconnect();
this._removeHintClasses();
this._super(...arguments);
this.options.wysiwyg.odooEditor.historyStep();
this._addHintClasses();
this._observer.observe(this._link, {subtree: true, childList: true, characterData: true});
},
this.props.onPreApplyLink();
super.applyLinkToDom(...arguments);
this.props.wysiwyg.odooEditor.historyStep();
this.props.onPostApplyLink();
this._observer.observe(this.props.link, {subtree: true, childList: true, characterData: true});
}
//--------------------------------------------------------------------------
// Public
@@ -114,190 +118,197 @@ const LinkTools = Link.extend({
* @override
*/
focusUrl() {
this.el.scrollIntoView();
this._super();
},
this.$el[0].scrollIntoView();
super.focusUrl(...arguments);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_setSelectOptionFromLink() {
super._setSelectOptionFromLink(...arguments);
const link = this.$link[0];
const customStyleProps = ['color', 'background-color', 'background-image', 'border-width', 'border-style', 'border-color'];
const shapeClasses = ['btn-outline-primary', 'btn-outline-secondary', 'btn-fill-primary', 'btn-fill-secondary', 'rounded-circle', 'flat'];
if (customStyleProps.some(s => link.style[s]) || shapeClasses.some(c => link.classList.contains(c))) {
// Force custom style if style or shape exists on the link.
const customOption = this.$el[0].querySelector('[name="link_style_color"] we-button[data-value="custom"]');
this._setSelectOption($(customOption), true);
}
}
/**
* @override
*/
_adaptPreview: function () {
_adaptPreview() {
var data = this._getData();
if (data === null) {
return;
}
this.applyLinkToDom(data);
},
}
/**
* @override
*/
_doStripDomain: function () {
return this.$('we-checkbox[name="do_strip_domain"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
},
_doStripDomain() {
return this.$el.find('we-checkbox[name="do_strip_domain"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
}
/**
* @override
*/
_getIsNewWindowFormRow() {
return this.$('we-checkbox[name="is_new_window"]').closest('we-row');
},
return this.$el.find('we-checkbox[name="is_new_window"]').closest('we-row');
}
/**
* @override
*/
_getLinkOptions: function () {
_getLinkOptions() {
const options = [
'we-selection-items[name="link_style_color"] > we-button',
'we-selection-items[name="link_style_size"] > we-button',
'we-selection-items[name="link_style_shape"] > we-button',
];
return this.$(options.join(','));
},
return this.$el.find(options.join(','));
}
/**
* @override
*/
_getLinkShape: function () {
return this.$('we-selection-items[name="link_style_shape"] we-button.active').data('value') || '';
},
_getLinkShape() {
return this.$el.find('we-selection-items[name="link_style_shape"] we-button.active').data('value') || '';
}
/**
* @override
*/
_getLinkSize: function () {
return this.$('we-selection-items[name="link_style_size"] we-button.active').data('value') || '';
},
_getLinkSize() {
return this.$el.find('we-selection-items[name="link_style_size"] we-button.active').data('value') || '';
}
/**
* @override
*/
_getLinkType: function () {
return this.$('we-selection-items[name="link_style_color"] we-button.active').data('value') || '';
},
_getLinkType() {
return this.$el.find('we-selection-items[name="link_style_color"] we-button.active').data('value') || '';
}
/**
* @override
*/
_getLinkCustomTextColor: function () {
_getLinkCustomTextColor() {
return this.customColors['color'];
},
}
/**
* @override
*/
_getLinkCustomBorder: function () {
_getLinkCustomBorder() {
return this.customColors['border-color'];
},
}
/**
* @override
*/
_getLinkCustomBorderWidth: function () {
return this.$('.link-custom-color-border input').val() || '';
},
_getLinkCustomBorderWidth() {
return this.$el.find('.link-custom-color-border input').val() || '';
}
/**
* @override
*/
_getLinkCustomBorderStyle: function () {
return this.$('.link-custom-color-border we-button.active').data('value') || '';
},
_getLinkCustomBorderStyle() {
return this.$el.find('.link-custom-color-border we-button.active').data('value') || '';
}
/**
* @override
*/
_getLinkCustomFill: function () {
_getLinkCustomFill() {
return this.customColors['background-color'];
},
}
/**
* @override
*/
_getLinkCustomClasses: function () {
_getLinkCustomClasses() {
let textClass = this.customColors['color'];
const colorPickerFg = this.colorpickers['color'];
const colorPickerFg = this.colorpickers['color'].colorNames;
if (
!textClass ||
!colorPickerFg ||
!computeColorClasses(colorPickerFg.getColorNames(), 'text-').includes(textClass)
!computeColorClasses(colorPickerFg, 'text-').includes(textClass)
) {
textClass = '';
}
let fillClass = this.customColors['background-color'];
const colorPickerBg = this.colorpickers['background-color'];
const colorPickerBg = this.colorpickers['background-color'].colorNames;
if (
!fillClass ||
!colorPickerBg ||
!computeColorClasses(colorPickerBg.getColorNames(), 'bg-').includes(fillClass)
!computeColorClasses(colorPickerBg, 'bg-').includes(fillClass)
) {
fillClass = '';
}
return ` ${textClass} ${fillClass}`;
},
}
/**
* @override
*/
_isNewWindow: function (url) {
if (this.options.forceNewWindow) {
_isNewWindow(url) {
if (this.props.forceNewWindow) {
return this._isFromAnotherHostName(url);
} else {
return this.$('we-checkbox[name="is_new_window"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
return this.$el.find('we-checkbox[name="is_new_window"]').closest('we-button.o_we_checkbox_wrapper').hasClass('active');
}
},
}
/**
* @override
*/
_setSelectOption: function ($option, active) {
_setSelectOption($option, active) {
$option.toggleClass('active', active);
if (active) {
$option.closest('we-select').find('we-toggler').text($option.text());
// ensure only one option is active in the dropdown
$option.siblings('we-button').removeClass("active");
}
},
}
/**
* @override
*/
_updateOptionsUI: function () {
const el = this.el.querySelector('[name="link_style_color"] we-button.active');
_updateOptionsUI() {
const el = this.$el[0].querySelector('[name="link_style_color"] we-button.active');
if (el) {
this.colorCombinationClass = el.dataset.value;
// Hide the size option if the link is an unstyled anchor.
for (const rowEl of this.el.querySelectorAll('.link-size-row')) {
for (const rowEl of this.linkComponentWrapperRef.el.querySelectorAll('.link-size-row')) {
rowEl.classList.toggle('d-none', !this.colorCombinationClass);
}
// Show custom colors and shape only for Custom style.
for (const rowEl of this.el.querySelectorAll('.link-custom-color, .link-shape-row')) {
for (const rowEl of this.linkComponentWrapperRef.el.querySelectorAll('.link-custom-color, .link-shape-row')) {
rowEl.classList.toggle('d-none', el.dataset.value !== 'custom');
}
this.props.onColorCombinationClassChange(this.colorCombinationClass);
// Note: the _updateColorpicker method is supposedly async but can
// be used synchronously given the fact that _addColorPicker was
// called during this widget initialization.
this._updateColorpicker('color');
this._updateColorpicker('background-color');
this._updateColorpicker('border-color');
const borderWidth = this.linkEl.style['border-width'];
const numberAndUnit = getNumericAndUnit(borderWidth);
this.$('.link-custom-color-border input').val(numberAndUnit ? numberAndUnit[0] : "1");
this.$el.find('.link-custom-color-border input').val(numberAndUnit ? numberAndUnit[0] : "1");
let borderStyle = this.linkEl.style['border-style'];
if (!borderStyle || borderStyle === 'none') {
borderStyle = 'solid';
}
const $activeBorderStyleButton = this.$(`.link-custom-color-border [name="link_border_style"] we-button[data-value="${borderStyle}"]`);
const $activeBorderStyleButton = this.$el.find(`.link-custom-color-border [name="link_border_style"] we-button[data-value="${borderStyle}"]`);
$activeBorderStyleButton.addClass('active');
$activeBorderStyleButton.siblings('we-button').removeClass("active");
const $activeBorderStyleToggler = $activeBorderStyleButton.closest('we-select').find('we-toggler');
$activeBorderStyleToggler.empty();
$activeBorderStyleButton.find('div').clone().appendTo($activeBorderStyleToggler);
}
},
}
/**
* Updates the colorpicker associated to a given property - updated with its selected color.
*
* @private
* @param {string} cssProperty
*/
_updateColorpicker: async function (cssProperty) {
_updateColorpicker(cssProperty) {
const prefix = this.PREFIXES[cssProperty];
let colorpicker = this.colorpickers[cssProperty];
// Update selected color.
const colorNames = colorpicker.getColorNames();
const colorNames = this.colorpickers[cssProperty].colorNames;
let color = this.linkEl.style[cssProperty];
const colorClasses = prefix ? computeColorClasses(colorNames, prefix) : [];
const colorClass = prefix && getColorClass(this.linkEl, colorNames, prefix);
@@ -313,14 +324,14 @@ const LinkTools = Link.extend({
this.customColors[cssProperty] = color;
if (cssProperty === 'border-color') {
// Highlight matching named color if any.
const colorName = colorpicker.colorToColorNames[ColorpickerWidget.normalizeCSSColor(color)];
colorpicker.setSelectedColor(null, colorName || color, false);
const colorName = colorNames[normalizeCSSColor(color)];
this.colorpickerProps[cssProperty].selectedColor = colorName || color;
} else {
colorpicker.setSelectedColor(null, isColorClass ? color.replace(prefix, '') : color, false);
this.colorpickerProps[cssProperty].selectedColor = isColorClass ? color.replace(prefix, '') : color;
}
// Update preview.
const $colorPreview = this.$('.link-custom-color-' + (cssProperty === 'border-color' ? 'border' : cssProperty === 'color' ? 'text' : 'fill') + ' .o_we_color_preview');
const $colorPreview = this.$el.find('.link-custom-color-' + (cssProperty === 'border-color' ? 'border' : cssProperty === 'color' ? 'text' : 'fill') + ' .o_we_color_preview');
const previewClasses = computeColorClasses(colorNames, 'bg-');
$colorPreview[0].classList.remove(...previewClasses);
if (isColorClass) {
@@ -330,85 +341,72 @@ const LinkTools = Link.extend({
$colorPreview.css('background-color', isColorGradient(color) ? 'rgba(0, 0, 0, 0)' : color);
$colorPreview.css('background-image', isColorGradient(color) ? color : '');
}
},
}
/**
* @private
* @param {string} cssProperty
*/
async _addColorPicker(cssProperty) {
const prefix = this.PREFIXES[cssProperty];
const colorpicker = new ColorPaletteWidget(this, {
excluded: ['transparent_grayscale'],
$editable: $(this.options.wysiwyg.odooEditor.editable),
withGradients: cssProperty === 'background-color',
});
this.colorpickers[cssProperty] = colorpicker;
await colorpicker.appendTo(document.createDocumentFragment());
colorpicker.on('custom_color_picked color_picked color_hover color_leave', this, (ev) => {
// Reset color styles in link content to make sure new color is not hidden.
// Only done when applied to avoid losing state during preview.
if (['custom_color_picked', 'color_picked'].includes(ev.name)) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(this.linkEl);
selection.removeAllRanges();
selection.addRange(range);
this.options.wysiwyg.odooEditor.execCommand('applyColor', '', 'color');
this.options.wysiwyg.odooEditor.execCommand('applyColor', '', 'backgroundColor');
}
_onColorpaletteSetColorNames(cssProperty, colorNames) {
this.colorpickers[cssProperty].colorNames = colorNames;
}
/**
* @private
*/
_onColorpaletteColorPicked(cssProperty, params) {
// Reset color styles in link content to make sure new color is not hidden.
// Only done when applied to avoid losing state during preview.
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(this.linkEl);
selection.removeAllRanges();
selection.addRange(range);
this.props.wysiwyg.odooEditor.execCommand('applyColor', '', 'color');
this.props.wysiwyg.odooEditor.execCommand('applyColor', '', 'backgroundColor');
let color = ev.data.color;
const colorNames = colorpicker.getColorNames();
const colorClasses = prefix ? computeColorClasses(colorNames, prefix) : [];
const colorClass = `${prefix}${color}`;
if (colorClasses.includes(colorClass)) {
color = colorClass;
} else if (colorNames.includes(color)) {
// Store as color value.
color = getCSSVariableValue(color);
}
this.customColors[cssProperty] = color;
this.applyLinkToDom(this._getData());
if (['custom_color_picked', 'color_picked'].includes(ev.name)) {
this.options.wysiwyg.odooEditor.historyStep();
this._updateOptionsUI();
}
});
return colorpicker;
},
this._colorpaletteApply(cssProperty, params);
this.props.wysiwyg.odooEditor.historyStep();
this._updateOptionsUI();
}
/**
* Add hint to the classes of the link and button.
* @private
*/
_addHintClasses () {
this.$link.addClass('oe_edited_link');
this.$button.addClass('active');
},
/**
* Remove hint to the classes of the link and button.
*/
_removeHintClasses () {
$(this.options.wysiwyg.odooEditor.document).find('.oe_edited_link').removeClass('oe_edited_link');
this.$button.removeClass('active');
},
_colorpaletteApply(cssProperty, params) {
const prefix = this.PREFIXES[cssProperty];
let color = params.color;
const colorNames = this.colorpickers[cssProperty].colorNames;
const colorClasses = prefix ? computeColorClasses(colorNames, prefix) : [];
const colorClass = `${prefix}${color}`;
if (colorClasses.includes(colorClass)) {
color = colorClass;
} else if (colorNames.includes(color)) {
// Store as color value.
color = getCSSVariableValue(color);
}
this.customColors[cssProperty] = color;
this.applyLinkToDom(this._getData());
}
/**
* Updates the label input with the DOM content of the link.
*
* @private
*/
_updateLabelInput() {
this.el.querySelector('#o_link_dialog_label_input').value = this.linkEl.innerText;
},
if (this.$el) {
this.$el[0].querySelector('#o_link_dialog_label_input').value = this.linkEl.innerText;
}
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
_onClickCheckbox: function (ev) {
_onClickCheckbox(ev) {
const $target = $(ev.target);
$target.closest('we-button.o_we_checkbox_wrapper').toggleClass('active');
this._adaptPreview();
},
_onPickSelectOption: function (ev) {
}
_onPickSelectOption(ev) {
const $target = $(ev.target);
if ($target.closest('[name="link_border_style"]').length) {
return;
@@ -418,37 +416,37 @@ const LinkTools = Link.extend({
this._setSelectOption($target, true);
this._updateOptionsUI();
this._adaptPreview();
},
}
/**
* Sets the border width on the link.
*
* @private
* @param {Event} ev
*/
_onChangeCustomBorderWidth: function (ev) {
_onChangeCustomBorderWidth(ev) {
const value = ev.target.value;
if (parseInt(value) >= 0) {
this.$link.css('border-width', value + 'px');
}
},
}
/**
* Sets the border width on the link when enter is pressed.
*
* @private
* @param {Event} ev
*/
_onKeyPressCustomBorderWidth: function (ev) {
_onKeyPressCustomBorderWidth(ev) {
if (ev.keyCode === $.ui.keyCode.ENTER) {
this._onChangeCustomBorderWidth(ev);
}
},
}
/**
* Sets the border style on the link.
*
* @private
* @param {Event} ev
*/
_onBorderStyleSelectOption: function (ev) {
_onBorderStyleSelectOption(ev) {
const value = ev.currentTarget.dataset.value;
if (value) {
this.$link.css('border-style', value);
@@ -459,18 +457,18 @@ const LinkTools = Link.extend({
// Ensure only one option is active in the dropdown.
$target.addClass('active');
$target.siblings('we-button').removeClass("active");
this.options.wysiwyg.odooEditor.historyStep();
this.props.wysiwyg.odooEditor.historyStep();
}
},
}
/**
* @override
*/
__onURLInput() {
this._super(...arguments);
this.options.wysiwyg.odooEditor.historyPauseSteps('_onURLInput');
super.__onURLInput(...arguments);
this.props.wysiwyg.odooEditor.historyPauseSteps('_onURLInput');
this._adaptPreview();
this.options.wysiwyg.odooEditor.historyUnpauseSteps('_onURLInput');
},
this.props.wysiwyg.odooEditor.historyUnpauseSteps('_onURLInput');
}
/**
* Updates the DOM content of the link with the input value.
*
@@ -486,8 +484,10 @@ const LinkTools = Link.extend({
// Force update of link's content with new data using 'force: true'.
// Without this, no update if input is same as original text.
this._updateLinkContent(this.$link, data, {force: true});
this._observer.observe(this._link, {subtree: true, childList: true, characterData: true});
},
});
this._observer.observe(this.props.link, {subtree: true, childList: true, characterData: true});
}
}
export default LinkTools;
export function shouldUnlink(link, colorCombinationClass) {
return !link.getAttribute('href') && !colorCombinationClass;
}
@@ -1,19 +0,0 @@
/** @odoo-module alias=wysiwyg.widgets **/
import Dialog from "wysiwyg.widgets.Dialog";
import AltDialog from "wysiwyg.widgets.AltDialog";
import LinkDialog from "wysiwyg.widgets.LinkDialog";
import LinkTools from "wysiwyg.widgets.LinkTools";
import ImageCropWidget from "wysiwyg.widgets.ImageCropWidget";
import LinkPopoverWidget from "@web_editor/js/wysiwyg/widgets/link_popover_widget";
import {ColorpickerDialog} from "web.Colorpicker";
export default {
Dialog: Dialog,
AltDialog: AltDialog,
LinkDialog: LinkDialog,
LinkTools: LinkTools,
ImageCropWidget: ImageCropWidget,
LinkPopoverWidget: LinkPopoverWidget,
ColorpickerDialog: ColorpickerDialog,
};
File diff suppressed because it is too large Load Diff
@@ -1,60 +1,39 @@
/** @odoo-module **/
import Wysiwyg from "web_editor.wysiwyg";
import ajax from "web.ajax";
import core from "web.core";
import config from "web.config";
import { Wysiwyg } from '@web_editor/js/wysiwyg/wysiwyg';
import { patch } from "@web/core/utils/patch";
import { getBundle } from "@web/core/assets";
import { isMobileOS } from "@web/core/browser/feature_detection";
var qweb = core.qweb;
var promiseJsAssets;
/**
* Add option (inIframe) to load Wysiwyg in an iframe.
**/
Wysiwyg.include({
patch(Wysiwyg.prototype, 'wysiwyg_iframe.js', {
/**
* Add options to load Wysiwyg in an iframe.
*
* @override
* @param {boolean} options.inIframe
**/
init: function (parent, options) {
this._super.apply(this, arguments);
init() {
this._super();
if (this.options.inIframe) {
this._onUpdateIframeId = 'onLoad_' + this.id;
}
},
/**
* Load assets to inject into iframe.
*
* @override
**/
willStart: async function () {
if (!this.options.inIframe) {
return this._super();
}
promiseJsAssets = promiseJsAssets || ajax.loadAsset('web_editor.wysiwyg_iframe_editor_assets');
const assetsPromises = [promiseJsAssets];
if (this.options.iframeCssAssets) {
assetsPromises.push(ajax.loadAsset(this.options.iframeCssAssets));
}
this.defAsset = Promise.all(assetsPromises);
const _super = this._super.bind(this);
await this.defAsset;
await _super();
},
/**
* @override
**/
startEdition: async function () {
async startEdition() {
const _super = this._super.bind(this);
if (!this.options.inIframe) {
return _super();
} else {
this.defAsset = this._getAssets();
await this.defAsset;
await this._loadIframe();
return _super();
}
@@ -67,7 +46,7 @@ Wysiwyg.include({
/**
* @override
**/
_getEditorOptions: function () {
_getEditorOptions() {
const options = this._super.apply(this, arguments);
options.getContextFromParentRect = () => {
return this.$iframe && this.$iframe.length ? this.$iframe[0].getBoundingClientRect() : { top: 0, left: 0 };
@@ -81,7 +60,7 @@ Wysiwyg.include({
* @private
* @returns {Promise}
*/
_loadIframe: function () {
_loadIframe() {
var self = this;
const isEditableRoot = this.$editable === this.$root;
this.$editable = $('<div class="note-editable oe_structure odoo-editor-editable"></div>');
@@ -110,7 +89,7 @@ Wysiwyg.include({
$targetClone.find('script').remove();
$iframeTarget.html($targetClone.html());
self.$iframeBody = $iframeTarget;
$iframeTarget.attr("isMobile", config.device.isMobile);
$iframeTarget.attr("isMobile", isMobileOS());
const $utilsZone = $('<div class="iframe-utils-zone">');
self.$utilsZone = $utilsZone;
@@ -145,7 +124,7 @@ Wysiwyg.include({
return;
}
var iframeContent = qweb.render('wysiwyg.iframeContent', {
const iframeContent = getWysiwygIframeContent({
assets: assets,
updateIframeId: self._onUpdateIframeId,
avoidDoubleLoad: _avoidDoubleLoad
@@ -166,13 +145,27 @@ Wysiwyg.include({
});
},
_insertSnippetMenu: function () {
_insertSnippetMenu() {
if (this.options.inIframe) {
return this.snippetsMenu.appendTo(this.$utilsZone);
} else {
return this._super.apply(this, arguments);
}
},
/**
* Get assets for the iframe.
*
* @private
* @returns {Promise}
*/
async _getAssets() {
promiseJsAssets = promiseJsAssets || await getBundle('web_editor.wysiwyg_iframe_editor_assets');
const assetsPromises = [promiseJsAssets];
if (this.options.iframeCssAssets) {
assetsPromises.push(getBundle(this.options.iframeCssAssets));
}
return Promise.all(assetsPromises);
},
/**
* Bind the blur event on the iframe so that it would not blur when using
@@ -180,7 +173,7 @@ Wysiwyg.include({
*
* @override
*/
_bindOnBlur: function () {
_bindOnBlur() {
if (!this.options.inIframe) {
this._super.apply(this, arguments);
} else {
@@ -188,3 +181,67 @@ Wysiwyg.include({
}
},
});
function getWysiwygIframeContent(params) {
const assets = {
cssLibs: [],
cssContents: [],
jsLibs: [],
jsContents: [],
};
for (const asset of params.assets) {
for (const cssLib of asset.cssLibs) {
assets.cssLibs.push(`<link type="text/css" rel="stylesheet" href="${cssLib}"/>`);
}
for (const cssContent of asset.cssContents) {
assets.cssContents.push(`<style type="text/css">${cssContent}</style>`);
}
for (const jsLib of asset.jsLibs) {
assets.jsLibs.push(`<script type="text/javascript" src="${jsLib}"/>`);
}
for (const jsContent of asset.jsContents) {
if (jsContent.indexOf('inline asset') !== -1) {
assets.jsContents.push(`<script type="text/javascript">${jsContent}</script>`);
}
}
}
return `
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
${assets.cssLibs.join('\n')}
${assets.cssContents.join('\n')}
${assets.jsLibs.join('\n')}
${assets.jsContents.join('\n')}
<script type="text/javascript">
odoo.define('web.session', [], function () {
return window.top.odoo.__DEBUG__.services['web.session'];
});
odoo.define('root.widget', ['web.Widget'], function (require) {
'use strict';
var Widget = require('web.Widget');
var widget = new Widget();
widget.appendTo(document.body);
return widget;
});
odoo.define('web.core.top', ['web.core'], function (require) {
var core = require('web.core');
core.qweb.templates = window.top.odoo.__DEBUG__.services['web.core'].qweb.templates;
});
</script>
</head>
<body class="o_in_iframe">
<div id="iframe_target"/>
<script type="text/javascript">
odoo.define('web_editor.wysiwyg.iniframe', [], function (require) {
'use strict';
if (window.top.${params.updateIframeId}) {
window.top.${params.updateIframeId}(${params.avoidDoubleLoad});
}
});
</script>
</body>`;
}
@@ -0,0 +1,101 @@
/** @odoo-module **/
// jQuery extensions
$.extend($.expr[':'], {
o_editable: function (node, i, m) {
while (node) {
if (node.className && typeof node.className === "string") {
if (node.className.indexOf('o_not_editable') !== -1) {
return false;
}
if (node.className.indexOf('o_editable') !== -1) {
return true;
}
}
node = node.parentNode;
}
return false;
},
});
function firstChild(node) {
while (node.firstChild) {
node = node.firstChild;
}
return node;
}
function lastChild(node) {
while (node.lastChild) {
node = node.lastChild;
}
return node;
}
function nodeLength(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue.length;
} else {
return node.childNodes.length;
}
}
$.fn.extend({
focusIn: function () {
if (this.length) {
const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
const node = firstChild(this[0]);
range.setStart(node, 0);
range.setEnd(node, 0);
selection.addRange(range);
}
return this;
},
focusInEnd: function () {
if (this.length) {
const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
const node = lastChild(this[0]);
const length = nodeLength(node);
range.setStart(node, length);
range.setEnd(node, length);
selection.addRange(range);
}
return this;
},
selectContent: function () {
if (this.length && !this[0].hasChildNodes()) {
return this.selectElement();
}
if (this.length) {
const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const range = new Range();
range.setStart(this[0].firstChild, 0);
range.setEnd(this[0].lastChild, this[0].lastChild.length);
selection.addRange(range);
}
return this;
},
selectElement: function () {
if (this.length) {
const selection = this[0].ownerDocument.getSelection();
selection.removeAllRanges();
const element = this[0];
const parent = element.parentNode;
const offsetStart = Array.from(parent.childNodes).indexOf(element);
const range = new Range();
range.setStart(parent, offsetStart);
range.setEnd(parent, offsetStart + 1);
selection.addRange(range);
}
return this;
},
});
+41 -45
View File
@@ -5,9 +5,9 @@
<!--=================-->
<!-- Editor toolbar -->
<t t-name="web_editor.toolbar">
<t t-name="web_editor.toolbar" owl="1">
<div id="toolbar" class="oe-toolbar oe-floating">
<div id="style" class="btn-group dropdown">
<div t-if="props.showStyle" id="style" t-attf-class="btn-group {{ props.dropDirection }}">
<button type="button" class="btn dropdown-toggle"
data-bs-toggle="dropdown" data-bs-original-title="Text style" tabindex="-1" aria-expanded="false">
<span title="Text style">Normal</span>
@@ -19,22 +19,22 @@
<li id="pre-dropdown-item">
<a class="dropdown-item" href="#" id="pre" data-call="setTag" data-arg1="pre"><pre>Code</pre></a>
</li>
<li id="heading1-dropdown-item">
<li t-if="props.showHeading1" id="heading1-dropdown-item">
<a class="dropdown-item" href="#" id="heading1" data-call="setTag" data-arg1="h1"><h1>Header 1</h1></a>
</li>
<li id="heading2-dropdown-item">
<li t-if="props.showHeading2" id="heading2-dropdown-item">
<a class="dropdown-item" href="#" id="heading2" data-call="setTag" data-arg1="h2"><h2>Header 2</h2></a>
</li>
<li id="heading3-dropdown-item">
<li t-if="props.showHeading3" id="heading3-dropdown-item">
<a class="dropdown-item" href="#" id="heading3" data-call="setTag" data-arg1="h3"><h3>Header 3</h3></a>
</li>
<li id="heading4-dropdown-item">
<li t-if="props.showHeading4" id="heading4-dropdown-item">
<a class="dropdown-item" href="#" id="heading4" data-call="setTag" data-arg1="h4"><h4>Header 4</h4></a>
</li>
<li id="heading5-dropdown-item">
<li t-if="props.showHeading5" id="heading5-dropdown-item">
<a class="dropdown-item" href="#" id="heading5" data-call="setTag" data-arg1="h5"><h5>Header 5</h5></a>
</li>
<li id="heading6-dropdown-item">
<li t-if="props.showHeading6" id="heading6-dropdown-item">
<a class="dropdown-item" href="#" id="heading6" data-call="setTag" data-arg1="h6"><h6>Header 6</h6></a>
</li>
<li id="blockquote-dropdown-item">
@@ -53,30 +53,29 @@
<div id="removeFormat" data-call="removeFormat" title="Remove format" class="btn fa fa-eraser fa-fw"></div>
</div>
<div id="colorInputButtonGroup" class="btn-group">
<div class="colorpicker-group note-fore-color-preview" data-name="color">
<div t-if="props.showColors" id="colorInputButtonGroup" class="btn-group">
<div class="colorpicker-group note-fore-color-preview" data-name="color" t-ref="textColorpickerDropdown" data-color-type="text">
<div id="oe-text-color" class="btn color-button dropdown-toggle editor-ignore"
data-bs-toggle="dropdown" tabindex="-1">
<i class="fa fa-font color-indicator fore-color" title="Font Color"></i>
</div>
<ul class="dropdown-menu colorpicker-menu">
<li><div data-event-name="foreColor" class="colorPalette"></div></li>
<li><ColorPalette t-props="props.textColorPaletteProps" /></li>
</ul>
</div>
<div class="colorpicker-group note-back-color-preview" data-name="color">
<div class="colorpicker-group note-back-color-preview" data-name="color" t-ref="backgroundColorpaletteDropdown" data-color-type="background">
<button id="oe-fore-color" type="button" class="btn dropdown-toggle editor-ignore"
data-bs-toggle="dropdown" tabindex="-1">
<i class="fa fa-paint-brush color-indicator hilite-color" title="Background Color"></i>
</button>
<ul class="dropdown-menu colorpicker-menu">
<li><div data-event-name="backColor" class="colorPalette"></div></li>
<li><ColorPalette t-props="props.backgroundColorPaletteProps" /></li>
</ul>
</div>
</div>
<t t-if="widget.isMobile()"></t>
<t t-else="">
<div id="font-size" class="btn-group dropdown">
<t t-if="!this.isMobile()">
<div t-if="props.showFontSize" id="font-size" t-attf-class="btn-group {{ props.dropDirection }}">
<button type="button" class="btn dropdown-toggle"
data-bs-toggle="dropdown" tabindex="-1"
data-bs-original-title="Font Size" aria-expanded="false">
@@ -101,7 +100,7 @@
</div>
</t>
<div id="justify" class="btn-group dropdown">
<div t-if="props.showJustify" id="justify" t-attf-class="btn-group {{ props.dropDirection }}">
<button type="button" class="btn dropdown-toggle"
data-bs-toggle="dropdown" tabindex="-1" data-bs-original-title="Paragraph" aria-expanded="false">
<i id="paragraphDropdownButton" class="fa fa-align-left fa-fw" title="Text align"></i>
@@ -116,17 +115,17 @@
</div>
</div>
<t t-if="widget.isMobile()">
<t t-if="this.isMobile()">
<button type="button" class="btn dropdown-toggle"
data-bs-toggle="dropdown" title="List" tabindex="-1"
data-bs-original-title="List" aria-expanded="false">
<i class="fa fa-list fa-fw"></i>
</button>
<div class="dropdown-menu">
<div id="list" class="btn-group">
<div t-if="props.showList" id="list" class="btn-group">
<div id="unordered" data-call="toggleList" data-arg1="UL" title="Toggle unordered list" class="oe-toggle-unordered fa fa-list-ul fa-fw btn"></div>
<div id="ordered" data-call="toggleList" data-arg1="OL" title="Toggle ordered list" class="oe-toggle-ordered fa fa-list-ol fa-fw btn"></div>
<div id="checklist" data-call="toggleList" data-arg1="CL" title="Toggle checklist" class="oe-toggle-checklist btn fa fa-fw">
<div t-if="props.showChecklist" id="checklist" data-call="toggleList" data-arg1="CL" title="Toggle checklist" class="oe-toggle-checklist btn fa fa-fw">
<div class="small">
<div class="fa fa-square-o d-block small"/>
<div class="fa fa-check-square d-block small"/>
@@ -136,10 +135,10 @@
</div>
</t>
<t t-else="">
<div id="list" class="btn-group">
<div t-if="props.showList" id="list" class="btn-group">
<div id="unordered" data-call="toggleList" data-arg1="UL" title="Toggle unordered list" class="oe-toggle-unordered fa fa-list-ul fa-fw btn"></div>
<div id="ordered" data-call="toggleList" data-arg1="OL" title="Toggle ordered list" class="oe-toggle-ordered fa fa-list-ol fa-fw btn"></div>
<div id="checklist" data-call="toggleList" data-arg1="CL" title="Toggle checklist" class="oe-toggle-checklist btn fa fa-fw">
<div t-if="props.showChecklist" id="checklist" data-call="toggleList" data-arg1="CL" title="Toggle checklist" class="oe-toggle-checklist btn fa fa-fw">
<div class="small">
<div class="fa fa-square-o d-block small"/>
<div class="fa fa-check-square d-block small"/>
@@ -152,21 +151,21 @@
<div id="image-fullscreen" title="Full screen" class="fa fa-search-plus btn editor-ignore"></div>
</div>
<div id="link" class="btn-group">
<div t-if="props.showLink" id="link" class="btn-group">
<div id="media-insert" title="Insert media" class="fa fa-file-image-o fa-fw btn editor-ignore"></div>
<div id="create-link" title="Insert or edit link" class="fa fa-link fa-fw btn editor-ignore"></div>
<div id="unlink" data-call="unlink" title="Remove link" class="fa fa-unlink fa-fw btn"></div>
<a id="media-description" href="#" title="Edit media description" class="btn editor-ignore">Description</a>
</div>
<div id="image-shape" class="btn-group">
<div t-if="props.showImageShape" id="image-shape" class="btn-group">
<div id="rounded" title="Shape: Rounded" class="fa fa-square fa-fw btn editor-ignore"></div>
<div id="rounded-circle" title="Shape: Circle" class="fa fa-circle-o fa-fw btn editor-ignore"></div>
<div id="shadow" title="Shadow" class="fa fa-sun-o fa-fw btn editor-ignore"></div>
<div id="img-thumbnail" title="Shape: Thumbnail" class="fa fa-picture-o fa-fw btn editor-ignore"></div>
</div>
<div id="image-padding" class="btn-group dropdown">
<div t-if="props.showImagePadding" id="image-padding" t-attf-class="btn-group {{ props.dropDirection }}">
<button type="button" class="btn dropdown-toggle"
data-bs-toggle="dropdown" tabindex="-1" aria-expanded="false">
<span class="fa fa-plus-square-o" title="Image padding"></span>
@@ -180,14 +179,14 @@
</ul>
</div>
<div id="image-width" class="btn-group">
<div t-if="props.showImageWidth" id="image-width" class="btn-group">
<div title="Resize Default" class="btn editor-ignore">Default</div>
<div id="100%" title="Resize Full" class="btn editor-ignore">100%</div>
<div id="50%" title="Resize Half" class="btn editor-ignore">50%</div>
<div id="25%" title="Resize Quarter" class="btn editor-ignore">25%</div>
</div>
<div id="image-edit" class="btn-group">
<div t-if="props.showImageEdit" id="image-edit" class="btn-group">
<div id="image-crop" title="Crop Image" class="btn fa fa-crop editor-ignore"></div>
<div id="image-transform" class="btn editor-ignore fa fa-object-ungroup"
title="Transform the picture (click twice to reset transformation)"></div>
@@ -208,9 +207,24 @@
<div class="btn-group only_fa">
<div id="fa-spin" title="Toggle icon spin" class="editor-ignore btn fa fa-play"></div>
</div>
<div t-if="props.showHistory" id="history" class="btn-group">
<div id="undo" data-call="undo" class="btn fa fa-undo"/>
<div id="redo" data-call="redo" class="btn fa fa-repeat"/>
</div>
<t t-slot="default"/>
</div>
</t>
<!-- Editor Toolbar -->
<!-- <t t-inherit="web_editor.toolbar" t-inherit-mode="extension" owl="1">
<xpath expr="//div[@id='checklist']" position="replace">aoeu</xpath>
<xpath expr="//div[@id='list']" position="inside">
<div title="Animate text" class="btn fa fa-play fa-fw o_we_animate_text"/>coucou4
</xpath>
</t> -->
<t t-name="web_editor.toolbar.table-options">
<we-customizeblock-options id="o-we-editor-table-container">
<we-title>
@@ -263,24 +277,6 @@
</we-customizeblock-options>
</t>
<!-- Collaboration reset dialog -->
<t t-name="web_editor.collaboration-reset-dialog">
<div>
<div style="color: red;">
<p>
There is a conflict between your version and the one in the database.
</p>
<p>
The version from the database will be used.
If you need to keep your changes, copy the content below and edit the new document.
</p>
<p style="font-weight: bold;">
Warning: after closing this dialog, the version you were working on will be discarded and will never be available anymore.
</p>
</div>
</div>
</t>
<!--=================-->
<!-- Snippet options -->
<!--=================-->
+48 -21
View File
@@ -57,43 +57,70 @@
</t>
<!-- options -->
<div t-name="web_editor.snippet.option.colorpicker" class="colorpicker">
<div t-name="web_editor.ColorPalette" class="colorpicker" owl="1" t-ref="el">
<div class="o_we_colorpicker_switch_panel d-flex justify-content-end px-2">
<t t-set="first_button_name"><t t-if="widget.withCombinations">Theme</t><t t-else="">Solid</t></t>
<button type="button" tabindex="1" class="o_we_colorpicker_switch_pane_btn" t-attf-data-target="#{widget.withCombinations? 'color-combinations' : 'theme-colors'}"
t-att-title="first_button_name">
<t t-set="first_button_name"><t t-if="props.withCombinations">Theme</t><t t-else="">Solid</t></t>
<button type="button" tabindex="1" class="o_we_colorpicker_switch_pane_btn" t-attf-data-target="#{props.withCombinations ? 'color-combinations' : 'theme-colors'}"
t-att-title="first_button_name"
t-on-click="this._onSwitchPaneButtonClick">
<span t-out="first_button_name"/>
</button>
<button type="button" tabindex="2" class="o_we_colorpicker_switch_pane_btn" data-target="custom-colors" title="Custom">
<button type="button" tabindex="2" class="o_we_colorpicker_switch_pane_btn" data-target="custom-colors" title="Custom"
t-on-click="this._onSwitchPaneButtonClick">
<span>Custom</span>
</button>
<button type="button" tabindex="3" class="o_we_colorpicker_switch_pane_btn" data-target="gradients" title="Gradient">
<button type="button" tabindex="3" class="o_we_colorpicker_switch_pane_btn" data-target="gradients" title="Gradient"
t-on-click="this._onSwitchPaneButtonClick">
<span>Gradient</span>
</button>
<t t-if="widget.resetButton">
<t t-set="trash_title"><t t-if="widget.withCombinations">None</t><t t-else="">Reset</t></t>
<button type="button" class="fa fa-trash my-1 ms-5 py-0 o_we_color_btn o_colorpicker_reset o_we_hover_danger" t-att-title="trash_title"/>
<t t-if="props.resetButton">
<t t-set="trash_title"><t t-if="props.withCombinations">None</t><t t-else="">Reset</t></t>
<button type="button" class="fa fa-trash my-1 ms-5 py-0 o_we_color_btn o_colorpicker_reset o_we_hover_danger" t-att-title="trash_title" />
</t>
</div>
<div class="o_colorpicker_sections pt-2 px-2 pb-3" data-color-tab="color-combinations">
<!-- List all Presets -->
<t t-foreach="5" t-as="i">
<t t-call="web_editor.colorpalette.combination.btn">
<t t-set="number" t-value="i + 1"/>
</t>
<t t-foreach="[1, 2, 3, 4, 5]" t-as="number" t-key="number">
<button type="button" class="o_we_color_btn o_we_color_combination_btn"
t-att-data-color="number" t-attf-title="Preset #{number}">
<t t-call="web_editor.color.combination.preview"/>
</button>
</t>
</div>
<div class="o_colorpicker_sections py-3 px-2" data-color-tab="theme-colors"/>
<div class="o_colorpicker_sections py-3 px-2" data-color-tab="custom-colors"/>
<div class="o_colorpicker_sections py-3 px-2" data-color-tab="gradients"/>
<div class="o_colorpicker_sections py-3 px-2" data-color-tab="custom-colors">
<div class="o_colorpicker_section_container"/>
<Colorpicker
document="this.props.document"
defaultColor="this.state.customDefaultColor"
noTransparency="!!this.props.noTransparency"
selectedColor="this.state.customSelectedColor"
onColorSelect.bind="_onColorPickerSelect"
onColorPreview.bind="_onColorPickerPreview"
onInputEnter="props.onInputEnter"/>
</div>
<div class="o_colorpicker_sections py-3 px-2" data-color-tab="gradients">
<div class="o_colorpicker_section_container"/>
<t t-if="state.showGradientPicker">
<Colorpicker
document="this.props.document"
stopClickPropagation="true"
selectedColor="this.state.gradientSelectedColor"
onColorSelect.bind="_onColorPickerSelectGradient"
onColorPreview.bind="_onColorPickerPreviewGradient"
onInputEnter="props.onInputEnter"/>
</t>
</div>
</div>
<t t-name="web_editor.colorpalette.combination.btn">
<button type="button" class="o_we_color_btn o_we_color_combination_btn"
t-att-data-color="number" t-attf-title="Preset #{number}">
<t t-call="web_editor.color.combination.preview"/>
</button>
<t t-name="web_editor.color.combination.preview" owl="1">
<div class="o_we_cc_preview_wrapper d-flex justify-content-between">
<h1 class="o_we_color_combination_btn_title">Title</h1>
<p class="o_we_color_combination_btn_text flex-grow-1">Text</p>
<span class="o_we_color_combination_btn_btn btn btn-sm btn-primary o_btn_preview me-1"><small>Button</small></span>
<span class="o_we_color_combination_btn_btn btn btn-sm btn-secondary o_btn_preview"><small>Button</small></span>
</div>
</t>
<t t-name="web_editor.color.combination.preview">
<t t-name="web_editor.color.combination.preview.legacy">
<div class="o_we_cc_preview_wrapper d-flex justify-content-between">
<h1 class="o_we_color_combination_btn_title">Title</h1>
<p class="o_we_color_combination_btn_text flex-grow-1">Text</p>
+272 -309
View File
@@ -1,348 +1,311 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<!--=================-->
<!-- Edition Iframe -->
<!--=================-->
<t t-name="wysiwyg.iframeContent"><head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
<t t-foreach="assets || []" t-as="asset">
<t t-foreach="asset.cssLibs || []" t-as="cssLib">
<link type="text/css" rel="stylesheet" t-att-href="cssLib"/>
</t>
<t t-foreach="asset.cssContents || []" t-as="cssContent">
<style type="text/css" t-out="cssContent"/>
</t>
<t t-foreach="asset.jsLibs || []" t-as="jsLib">
<script type="text/javascript" t-att-src="jsLib"/>
</t>
<t t-foreach="asset.jsContents || []" t-as="jsContent" t-if="jsContent.indexOf('inline asset') !== -1">
<script type="text/javascript" t-out="jsContent"/>
</t>
</t>
<script type="text/javascript">
odoo.define('web.session', [], function () {
return window.top.odoo.__DEBUG__.services['web.session'];
});
odoo.define('root.widget', ['web.Widget'], function (require) {
'use strict';
var Widget = require('web.Widget');
var widget = new Widget();
widget.appendTo(document.body);
return widget;
});
odoo.define('web.core.top', ['web.core'], function (require) {
var core = require('web.core');
core.qweb.templates = window.top.odoo.__DEBUG__.services['web.core'].qweb.templates;
});
</script>
</head>
<body class="o_in_iframe">
<div id="iframe_target"/>
<script type="text/javascript">
odoo.define('web_editor.wysiwyg.iniframe', [], function (require) {
'use strict';
if (window.top.<t t-esc="updateIframeId"/>) {
window.top.<t t-esc="updateIframeId"/>(<t t-esc="avoidDoubleLoad"/>);
}
});
</script>
</body>
<t t-name="web_editor.Wysiwyg" owl="1">
<div style="display: contents;" t-ref="toolbar">
<Toolbar t-props="this.state.toolbarProps" t-if="this.state.showToolbar"/>
</div>
<div style="display: contents;" t-ref="imageCrop">
<ImageCrop t-props="this.imageCropProps"/>
</div>
<div t-ref="el"/>
</t>
<!--=================-->
<!-- Edition Dialogs -->
<!--=================-->
<!-- Alt Dialog (allows to change alt and title of page images) -->
<form t-name="wysiwyg.widgets.alt" action="#">
<div class="mb-3 row">
<label class="col-md-3 col-form-label" for="alt"
title="'Alt tag' specifies an alternate text for an image, if the image cannot be displayed (slow connection, missing image, screen reader ...).">
Description <small>(ALT Tag)</small>
</label>
<div class="col-md-8">
<input class="form-control" id="alt" required="required" t-att-value="widget.alt" type="text"/>
</div>
</div>
<div class="mb-3 row">
<label class="col-md-3 col-form-label" for="title"
title="'Title tag' is shown as a tooltip when you hover the picture.">
Tooltip <small>(TITLE Tag)</small>
</label>
<div class="col-md-8">
<input class="form-control" id="title" required="required" t-att-value="widget.tag_title" type="text"/>
</div>
</div>
</form>
<!-- Link Dialog (allows to choose a style and content for a link on the page) -->
<div t-name="wysiwyg.widgets.link" class="o_link_dialog">
<div class="row">
<form class="col-lg-8">
<div t-attf-class="mb-3 row#{widget.needLabel ? '' : ' d-none'}">
<label class="col-form-label col-md-3" for="o_link_dialog_label_input">Link Label</label>
<div class="col-md-9">
<input type="text" name="label" class="form-control" id="o_link_dialog_label_input" required="required" t-att-value="widget.data.content"/>
</div>
</div>
<div t-attf-class="mb-3 row o_url_input#{widget.isButton ? ' d-none' : ''}">
<label class="col-form-label col-md-3" for="o_link_dialog_url_input">URL or Email</label>
<div class="col-md-9">
<input type="text" name="url" class="form-control" id="o_link_dialog_url_input" required="required"/>
<div class="form-check o_strip_domain d-none">
<input type="checkbox" id="o_link_dialog_url_strip_domain" checked="checked" class="form-check-input"/>
<label for="o_link_dialog_url_strip_domain" class="form-check-label fw-normal">
Autoconvert to relative link
</label>
<div t-name="web_editor.LinkDialog" owl="1">
<Dialog size="'xl'">
<div class="o_link_dialog">
<div class="row" t-ref="linkComponentWrapper">
<form class="col-lg-8">
<div t-attf-class="mb-3 row#{props.needLabel ? '' : ' d-none'}">
<label class="col-form-label col-md-3" for="o_link_dialog_label_input">Link Label</label>
<div class="col-md-9">
<input type="text" name="label" class="form-control" id="o_link_dialog_label_input" required="required" t-att-value="this.state.initialContent" t-ref="inputText"/>
</div>
</div>
</div>
</div>
<div class="mb-3 row">
<label class="col-form-label col-md-3">Type</label>
<div class="col-md-9 d-flex align-items-center">
<div t-attf-class="#{widget.colorCombinationClass ? ('p-2 ' + widget.colorCombinationClass) : ''}">
<t t-foreach="widget.colorsData" t-as="colorData">
<label role="button" class="m-0 me-2" t-if="colorData.type !== 'custom'">
<input type="radio" name="link_style_color" class="d-none link-style" t-att-value="colorData.type"/>
<span t-esc="colorData.label"
t-attf-class="o_btn_preview btn btn-sm btn-#{colorData.btnPreview} #{colorData.type ? '' : 'px-0'}"/>
<div t-attf-class="mb-3 row o_url_input#{state.isButton ? ' d-none' : ''}">
<label class="col-form-label col-md-3" for="o_link_dialog_url_input">URL or Email</label>
<div class="col-md-9">
<input type="text" name="url" class="form-control" id="o_link_dialog_url_input" required="required" t-ref="inputUrl"/>
<div class="form-check o_strip_domain d-none">
<input type="checkbox" id="o_link_dialog_url_strip_domain" checked="checked" class="form-check-input"/>
<label for="o_link_dialog_url_strip_domain" class="form-check-label fw-normal">
Autoconvert to relative link
</label>
</div>
</div>
</div>
<div class="mb-3 row">
<label class="col-form-label col-md-3">Type</label>
<div class="col-md-9 d-flex align-items-center">
<div t-attf-class="#{this.colorCombinationClass ? ('p-2 ' + this.colorCombinationClass) : ''}">
<t t-foreach="this.colorsData" t-as="colorData" t-key="colorData.type">
<label role="button" class="m-0 me-2" t-if="colorData.type !== 'custom'">
<input type="radio" name="link_style_color" class="d-none link-style" t-att-value="colorData.type"/>
<span t-esc="colorData.label"
t-attf-class="o_btn_preview btn btn-sm btn-#{colorData.btnPreview} #{colorData.type ? '' : 'px-0'}"/>
</label>
</t>
</div>
</div>
</div>
<div id="o_link_dialog_button_opts_collapse" class="collapse">
<div class="mb-3 row">
<label class="col-form-label col-md-3">Size</label>
<div class="col-md-9">
<select name="link_style_size" class="form-select link-style">
<option value="sm">Small</option>
<option value="" selected="selected">Medium</option>
<option value="lg">Large</option>
</select>
</div>
</div>
<div class="mb-3 row">
<label class="col-form-label col-md-3">Style</label>
<div class="col-md-9">
<select name="link_style_shape" class="form-select link-style">
<option value="" selected="selected">Default</option>
<option value="rounded-circle">Default + Rounded</option>
<option value="outline">Outline</option>
<option value="outline,rounded-circle">Outline + Rounded</option>
<option value="fill">Fill</option>
<option value="fill,rounded-circle">Fill + Rounded</option>
<option value="flat">Flat</option>
</select>
</div>
</div>
</div>
<div class="mb-3 row" t-if="!state.isButton and !props.forceNewWindow">
<div class="offset-md-3 col-md-9">
<label class="o_switch">
<input type="checkbox" name="is_new_window" t-att-checked="this.initialIsNewWindow ? 'checked' : undefined"/>
<span/>
Open in new window
</label>
</t>
</div>
</div>
</form>
<div class="col-lg-4 o_link_dialog_preview">
<div class="mb-3 text-center">
<label>Preview</label>
<div t-attf-class="#{this.colorCombinationClass ? ('p-2 ' + this.colorCombinationClass) : ''}"
style="overflow-x: auto; max-width: 100%; max-height: 200px;">
<a href="#" id="link-preview" aria-label="Preview" title="Preview"/>
</div>
</div>
</div>
</div>
<div id="o_link_dialog_button_opts_collapse" class="collapse">
<div class="mb-3 row">
<label class="col-form-label col-md-3">Size</label>
<div class="col-md-9">
<select name="link_style_size" class="form-select link-style">
<option value="sm">Small</option>
<option value="" selected="selected">Medium</option>
<option value="lg">Large</option>
</select>
</div>
</div>
<div class="mb-3 row">
<label class="col-form-label col-md-3">Style</label>
<div class="col-md-9">
<select name="link_style_shape" class="form-select link-style">
<option value="" selected="selected">Default</option>
<option value="rounded-circle">Default + Rounded</option>
<option value="outline">Outline</option>
<option value="outline,rounded-circle">Outline + Rounded</option>
<option value="fill">Fill</option>
<option value="fill,rounded-circle">Fill + Rounded</option>
<option value="flat">Flat</option>
</select>
</div>
</div>
</div>
<div class="mb-3 row" t-if="!widget.isButton &amp;&amp; !widget.options.forceNewWindow">
<div class="offset-md-3 col-md-9">
<label class="o_switch">
<input type="checkbox" name="is_new_window" t-att-checked="widget.data.isNewWindow ? 'checked' : undefined"/>
<span/>
Open in new window
</label>
</div>
</div>
</form>
<div class="col-lg-4 o_link_dialog_preview">
<div class="mb-3 text-center">
<label>Preview</label>
<div t-attf-class="#{widget.colorCombinationClass ? ('p-2 ' + widget.colorCombinationClass) : ''}"
style="overflow-x: auto; max-width: 100%; max-height: 200px;">
<a href="#" id="link-preview" aria-label="Preview" title="Preview"/>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="this.onSave">Save</button>
<button class="btn btn-secondary" t-on-click="this.props.close">Discard</button>
</t>
</div>
</div>
</Dialog>
</div>
<!-- Link Edition : Popover -->
<div t-name="wysiwyg.widgets.link.edit.tooltip" class="d-flex">
<span class="me-2 o_we_preview_favicon"><i class="fa fa-globe"/><img class="align-baseline d-none"/></span>
<div class="w-100">
<div class="d-flex">
<a href="#" target="_blank" class="o_we_url_link fw-bold flex-grow-1 text-truncate" t-esc="widget.href" title="Open in a new tab"/>
<a href="#" class="mx-1 o_we_copy_link text-dark" data-bs-toggle="tooltip" data-bs-placement="top" title="Copy Link">
<i class="fa fa-clone"/>
</a>
<a href="#" class="mx-1 o_we_edit_link text-dark" data-bs-toggle="tooltip" data-bs-placement="top" title="Edit Link">
<i class="fa fa-edit"/>
</a>
<a href="#" class="ms-1 o_we_remove_link text-dark" data-bs-toggle="tooltip" data-bs-placement="top" title="Remove Link">
<i class="fa fa-chain-broken"/>
</a>
</div>
<a href="#" target="_blank" class="o_we_full_url mt-1 text-muted d-none" t-esc="widget.href" title="Open in a new tab"/>
</div>
</div>
<we-customizeblock-option t-name="wysiwyg.widgets.linkTools">
<we-row id="url_row" t-attf-class="#{widget.isButton ? ' d-none' : ''}">
<we-input class="o_we_user_value_widget o_we_sublevel_1">
<we-title class="o_short_title"></we-title>
<div class="o_url_input">
<input name="url" id="o_link_dialog_url_input" type="text" placeholder="Your URL"/>
</div>
</we-input>
</we-row>
<we-row class="o_strip_domain d-none" t-attf-class="#{widget.isButton ? ' d-none' : ''}">
<we-button class="o_we_user_value_widget o_we_checkbox_wrapper o_we_sublevel_1 active">
<we-title class="o_long_title">Autoconvert to relative link</we-title>
<div class="o_switch">
<we-checkbox name="do_strip_domain" t-att-checked="widget.data.doStripDomain ? 'checked' : undefined"/>
</div>
</we-button>
</we-row>
<we-row t-attf-class="o_we_sublevel_1 {{widget.needLabel ? '' : 'd-none'}}">
<we-title>Label</we-title>
<we-input class="o_we_user_value_widget">
<div>
<input name="label" id="o_link_dialog_label_input" t-att-value="widget.data.content" type="text"/>
</div>
</we-input>
</we-row>
<we-row>
<we-select class="o_we_user_value_widget o_we_sublevel_1">
<we-title>Style</we-title>
<div class="dropdown">
<button class="dropdown-toggle" data-bs-toggle="dropdown" tabindex="-1" aria-expanded="false">
<we-toggler title="Link Style"/>
</button>
<we-selection-items class="dropdown-menu" name="link_style_color">
<t t-foreach="widget.colorsData" t-as="colorData">
<we-button class="dropdown-item" href="#" t-att-data-value="colorData.type">
<t t-esc="colorData.label"/>
</we-button>
</t>
</we-selection-items>
<span class="o_we_dropdown_caret"></span>
</div>
</we-select>
</we-row>
<we-row class="link-custom-color link-custom-color-text">
<we-select title="Color" class="o_we_user_value_widget o_we_so_color_palette o_we_sublevel_2">
<we-title>Text Color</we-title>
<div class="dropdown">
<span data-bs-toggle="dropdown" data-css-property="color" class="o_we_color_preview"></span>
<we-selection-items class="dropdown-menu" name="link_text_color"/>
</div>
</we-select>
</we-row>
<we-row class="link-custom-color link-custom-color-fill">
<we-select title="Color" class="o_we_user_value_widget o_we_so_color_palette o_we_sublevel_2">
<we-title>Fill Color</we-title>
<div class="dropdown">
<span data-bs-toggle="dropdown" data-css-property="background-color" class="o_we_color_preview"></span>
<we-selection-items class="dropdown-menu" name="link_fill_color"/>
</div>
</we-select>
</we-row>
<we-row class="link-custom-color link-custom-color-border o_we_sublevel_2">
<we-title>Border</we-title>
<div>
<we-input class="o_we_user_value_widget" title="Border Width">
<div class="o_custom_border_width_input">
<input name="custom_border_width" type="number" class="text-end" size="2"/>
<span>px</span>
<we-customizeblock-option t-name="web_editor.LinkTools" owl="1">
<div style="display: contents" t-ref="linkComponentWrapper">
<we-row id="url_row" t-attf-class="#{state.isButton ? ' d-none' : ''}">
<we-input class="o_we_user_value_widget o_we_sublevel_1">
<we-title class="o_short_title"></we-title>
<div class="o_url_input">
<input name="url" id="o_link_dialog_url_input" type="text" placeholder="Your URL"/>
</div>
</we-input>
<we-select class="o_we_user_value_widget" title="Border Style">
<div class="dropdown">
<button class="dropdown-toggle"
data-bs-toggle="dropdown" title="" tabindex="-1"
data-bs-original-title="Link Border Style" aria-expanded="false">
<we-toggler/>
</button>
<we-selection-items class="dropdown-menu" name="link_border_style">
<we-button title="Solid" class="dropdown-item" data-value="solid"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: solid;"/></we-button>
<we-button title="Dashed" class="dropdown-item" data-value="dashed"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dashed;"/></we-button>
<we-button title="Dotted" class="dropdown-item" data-value="dotted"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dotted;"/></we-button>
<we-button title="Double" class="dropdown-item" data-value="double"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: double; border-left: none; border-right: none;"/></we-button>
</we-selection-items>
</we-row>
<we-row class="o_strip_domain d-none" t-attf-class="#{state.isButton ? ' d-none' : ''}">
<we-button class="o_we_user_value_widget o_we_checkbox_wrapper o_we_sublevel_1 active">
<we-title class="o_long_title">Autoconvert to relative link</we-title>
<div class="o_switch">
<we-checkbox name="do_strip_domain"/>
</div>
</we-button>
</we-row>
<we-row t-attf-class="o_we_sublevel_1 {{props.needLabel ? '' : 'd-none'}}">
<we-title>Label</we-title>
<we-input class="o_we_user_value_widget">
<div>
<input name="label" id="o_link_dialog_label_input" t-att-value="this.state.initialContent" type="text"/>
</div>
</we-select>
<we-select title="Border Color" class="o_we_user_value_widget o_we_so_color_palette">
</we-input>
</we-row>
<we-row>
<we-select class="o_we_user_value_widget o_we_sublevel_1">
<we-title>Style</we-title>
<div class="dropdown">
<span data-bs-toggle="dropdown" data-css-property="border-color" class="o_we_color_preview"></span>
<we-selection-items class="dropdown-menu" name="link_border_color"/>
</div>
</we-select>
</div>
</we-row>
<we-row class="link-size-row">
<we-select class="o_we_user_value_widget o_we_sublevel_2">
<we-title>Size</we-title>
<div>
<div class="dropdown">
<button class="dropdown-toggle" data-bs-toggle="dropdown" title="" tabindex="-1" aria-expanded="false">
<we-toggler title="Link Size">
Medium
</we-toggler>
<button class="dropdown-toggle" data-bs-toggle="dropdown" tabindex="-1" aria-expanded="false">
<we-toggler title="Link Style"/>
</button>
<we-selection-items name="link_style_size" class="dropdown-menu link-style">
<we-button class="dropdown-item" href="#" data-value="sm">
Small
</we-button>
<we-button class="dropdown-item active" href="#" data-value="">
Medium
</we-button>
<we-button class="dropdown-item" href="#" data-value="lg">
Large
</we-button>
<we-selection-items class="dropdown-menu" name="link_style_color">
<t t-foreach="this.colorsData" t-as="colorData" t-key="colorData.type">
<we-button class="dropdown-item" href="#" t-att-data-value="colorData.type">
<t t-esc="colorData.label"/>
</we-button>
</t>
</we-selection-items>
<span class="o_we_dropdown_caret"></span>
</div>
</div>
</we-select>
</we-row>
<we-row class="link-shape-row">
<we-select class="o_we_user_value_widget o_we_sublevel_2">
<we-title>Shape</we-title>
<div>
</we-select>
</we-row>
<we-row class="link-custom-color link-custom-color-text">
<we-select title="Color" class="o_we_user_value_widget o_we_so_color_palette o_we_sublevel_2">
<we-title>Text Color</we-title>
<div class="dropdown">
<button class="dropdown-toggle" data-bs-toggle="dropdown" tabindex="-1" aria-expanded="false">
<we-toggler title="Link Shape">
Default
</we-toggler>
</button>
<we-selection-items name="link_style_shape" class="dropdown-menu link-style">
<we-button class="dropdown-item active" data-value="">Default</we-button>
<we-button class="dropdown-item" data-value="rounded-circle">Default + Rounded</we-button>
<we-button class="dropdown-item" data-value="outline">Outline</we-button>
<we-button class="dropdown-item" data-value="outline,rounded-circle">Outline + Rounded</we-button>
<we-button class="dropdown-item" data-value="fill">Fill</we-button>
<we-button class="dropdown-item" data-value="fill,rounded-circle">Fill + Rounded</we-button>
<we-button class="dropdown-item" data-value="flat">Flat</we-button>
<span data-bs-toggle="dropdown" data-css-property="color" class="o_we_color_preview"></span>
<we-selection-items class="dropdown-menu" name="link_text_color">
<ColorPalette
selectedColor="colorpickerProps['color'].selectedColor"
excluded="['transparent_grayscale']"
document="this.props.wysiwyg.options.document"
onSetColorNames="(colorNames) => this._onColorpaletteSetColorNames('color', colorNames)"
onColorHover="(params) => this._colorpaletteApply('color', params)"
onColorLeave="(params) => this._colorpaletteApply('color', params)"
onColorPicked="(params) => this._onColorpaletteColorPicked('color', params)"
onCustomColorPicked="(params) => this._onColorpaletteColorPicked('color', params)"
/>
</we-selection-items>
</div>
</div>
</we-select>
</we-row>
<we-row t-if="!widget.isButton &amp;&amp; !widget.options.forceNewWindow">
<we-button t-attf-class="o_we_user_value_widget o_we_checkbox_wrapper o_we_sublevel_1 #{widget.data.isNewWindow ? 'active' : ''}">
<we-title class="o_long_title">Open in new window</we-title>
<div class="o_switch">
<we-checkbox name="is_new_window" t-att-checked="widget.data.isNewWindow ? 'checked' : undefined"/>
</we-select>
</we-row>
<we-row class="link-custom-color link-custom-color-fill">
<we-select title="Color" class="o_we_user_value_widget o_we_so_color_palette o_we_sublevel_2">
<we-title>Fill Color</we-title>
<div class="dropdown">
<span data-bs-toggle="dropdown" data-css-property="background-color" class="o_we_color_preview"></span>
<we-selection-items class="dropdown-menu" name="link_fill_color">
<ColorPalette
selectedColor="colorpickerProps['background-color'].selectedColor"
excluded="['transparent_grayscale']"
document="this.props.wysiwyg.options.document"
withGradients="true"
onSetColorNames="(colorNames) => this._onColorpaletteSetColorNames('background-color', colorNames)"
onColorHover="(params) => this._colorpaletteApply('background-color', params)"
onColorLeave="(params) => this._colorpaletteApply('background-color', params)"
onColorPicked="(params) => this._onColorpaletteColorPicked('background-color', params)"
onCustomColorPicked="(params) => this._onColorpaletteColorPicked('background-color', params)"
/>
</we-selection-items>
</div>
</we-button>
</we-row>
</we-select>
</we-row>
<we-row class="link-custom-color link-custom-color-border o_we_sublevel_2">
<we-title>Border</we-title>
<div>
<we-input class="o_we_user_value_widget" title="Border Width">
<div class="o_custom_border_width_input">
<input name="custom_border_width" type="number" class="text-end" size="2"/>
<span>px</span>
</div>
</we-input>
<we-select class="o_we_user_value_widget" title="Border Style">
<div class="dropdown">
<button class="dropdown-toggle"
data-bs-toggle="dropdown" title="" tabindex="-1"
data-bs-original-title="Link Border Style" aria-expanded="false">
<we-toggler/>
</button>
<we-selection-items class="dropdown-menu" name="link_border_style">
<we-button title="Solid" class="dropdown-item" data-value="solid"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: solid;"/></we-button>
<we-button title="Dashed" class="dropdown-item" data-value="dashed"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dashed;"/></we-button>
<we-button title="Dotted" class="dropdown-item" data-value="dotted"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: dotted;"/></we-button>
<we-button title="Double" class="dropdown-item" data-value="double"><div class="o_we_fake_img_item o_we_border_preview" style="border-style: double; border-left: none; border-right: none;"/></we-button>
</we-selection-items>
</div>
</we-select>
<we-select title="Border Color" class="o_we_user_value_widget o_we_so_color_palette">
<div class="dropdown">
<span data-bs-toggle="dropdown" data-css-property="border-color" class="o_we_color_preview"></span>
<we-selection-items class="dropdown-menu" name="link_border_color">
<ColorPalette
selectedColor="colorpickerProps['border-color'].selectedColor"
excluded="['transparent_grayscale']"
document="this.props.wysiwyg.options.document"
onSetColorNames="(colorNames) => this._onColorpaletteSetColorNames('border-color', colorNames)"
onColorHover="(params) => this._colorpaletteApply('border-color', params)"
onColorLeave="(params) => this._colorpaletteApply('border-color', params)"
onColorPicked="(params) => this._onColorpaletteColorPicked('border-color', params)"
onCustomColorPicked="(params) => this._onColorpaletteColorPicked('border-color', params)"
/>
</we-selection-items>
</div>
</we-select>
</div>
</we-row>
<we-row class="link-size-row">
<we-select class="o_we_user_value_widget o_we_sublevel_2">
<we-title>Size</we-title>
<div>
<div class="dropdown">
<button class="dropdown-toggle" data-bs-toggle="dropdown" title="" tabindex="-1" aria-expanded="false">
<we-toggler title="Link Size">
Medium
</we-toggler>
</button>
<we-selection-items name="link_style_size" class="dropdown-menu link-style">
<we-button class="dropdown-item" href="#" data-value="sm">
Small
</we-button>
<we-button class="dropdown-item active" href="#" data-value="">
Medium
</we-button>
<we-button class="dropdown-item" href="#" data-value="lg">
Large
</we-button>
</we-selection-items>
<span class="o_we_dropdown_caret"></span>
</div>
</div>
</we-select>
</we-row>
<we-row class="link-shape-row">
<we-select class="o_we_user_value_widget o_we_sublevel_2">
<we-title>Shape</we-title>
<div>
<div class="dropdown">
<button class="dropdown-toggle" data-bs-toggle="dropdown" tabindex="-1" aria-expanded="false">
<we-toggler title="Link Shape">
Default
</we-toggler>
</button>
<we-selection-items name="link_style_shape" class="dropdown-menu link-style">
<we-button class="dropdown-item active" data-value="">Default</we-button>
<we-button class="dropdown-item" data-value="rounded-circle">Default + Rounded</we-button>
<we-button class="dropdown-item" data-value="outline">Outline</we-button>
<we-button class="dropdown-item" data-value="outline,rounded-circle">Outline + Rounded</we-button>
<we-button class="dropdown-item" data-value="fill">Fill</we-button>
<we-button class="dropdown-item" data-value="fill,rounded-circle">Fill + Rounded</we-button>
<we-button class="dropdown-item" data-value="flat">Flat</we-button>
</we-selection-items>
</div>
</div>
</we-select>
</we-row>
<we-row t-if="!state.isButton and !props.forceNewWindow">
<we-button t-attf-class="o_we_user_value_widget o_we_checkbox_wrapper o_we_sublevel_1 #{this.initialIsNewWindow ? 'active' : ''}">
<we-title class="o_long_title">Open in new window</we-title>
<div class="o_switch">
<we-checkbox name="is_new_window" t-att-checked="this.initialIsNewWindow ? 'checked' : undefined"/>
</div>
</we-button>
</we-row>
</div>
</we-customizeblock-option>
<!-- ImageCropWidget controls (allows to crop images on the page) -->
<div t-name="wysiwyg.widgets.crop" class="o_we_crop_widget" contenteditable="false">
<!-- ImageCrop controls (allows to crop images on the page) -->
<div t-name="web_editor.ImageCrop" class="o_we_crop_widget" contenteditable="false"
t-att-class="this.state.active ? '' : 'd-none'"
t-ref="el"
owl="1">
<div class="o_we_cropper_wrapper">
<img class="o_we_cropper_img"/>
<div class="o_we_crop_buttons text-center mt16 position-fixed o_we_no_overlay" contenteditable="false">
<div class="btn-group btn-group-toggle" title="Aspect Ratio" data-bs-toggle="buttons">
<t t-foreach="widget.aspectRatios" t-as="ratio">
<t t-set="is_active" t-value="ratio === widget.aspectRatio"/>
<t t-foreach="this.aspectRatios" t-as="ratio" t-key="ratio">
<t t-set="is_active" t-value="ratio === this.aspectRatio"/>
<label t-attf-class="btn #{is_active and 'active' or ''}" data-action="ratio" t-att-data-value="ratio">
<input type="radio" /><t t-esc="ratio_value.label"/>
</label>
@@ -1,8 +0,0 @@
/** @odoo-module **/
import loaderFunctions from "web_editor.loader";
loaderFunctions.createWysiwyg = (parent, options) => {
const Wysiwyg = odoo.__DEBUG__.services['web_editor.wysiwyg'];
return new Wysiwyg(parent, options.wysiwygOptions);
};
+27 -71
View File
@@ -1,11 +1,12 @@
/** @odoo-module alias=web_editor.test_utils **/
import ajax from "web.ajax";
import MockServer from "web.MockServer";
import { MockServer } from "@web/../tests/helpers/mock_server";
import MockServerLegacy from "web.MockServer";
import testUtils from "web.test_utils";
import * as coreUtils from "@web/core/utils/patch";
import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor";
import Widget from "web.Widget";
import Wysiwyg from "web_editor.wysiwyg";
import { Wysiwyg } from '@web_editor/js/wysiwyg/wysiwyg';
import options from "web_editor.snippets.options";
import { range } from "@web/core/utils/numbers";
import { TABLE_ATTRIBUTES, TABLE_STYLES } from '@web_editor/js/backend/convert_inline';
@@ -94,7 +95,29 @@ const SNIPPETS_TEMPLATE = `
</div>
</div>`;
MockServer.include({
coreUtils.patch(MockServer.prototype, 'web_editor', {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
* @returns {Promise}
*/
async _performRPC(route, args) {
if (args.model === "ir.ui.view" && args.method === 'render_public_asset') {
if (args.args[0] === "web_editor.colorpicker") {
return COLOR_PICKER_TEMPLATE;
}
if (args.args[0] === "web_editor.snippets") {
return SNIPPETS_TEMPLATE;
}
}
return this._super(...arguments);
},
});
MockServerLegacy.include({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -145,26 +168,6 @@ options.registry.option_test = options.Class.extend({
},
});
/**
* Constructor WysiwygTest why editable and unbreakable node used in test.
*/
var WysiwygTest = Wysiwyg.extend({
_parentToDestroyForTest: null,
/**
* Override 'destroy' of discuss so that it calls 'destroy' on the parent.
*
* @override
*/
destroy: function () {
unpatch();
this._super();
this.$target.remove();
this._parentToDestroyForTest.destroy();
},
});
function patch() {
testUtils.mock.patch(ajax, {
loadAsset: function (xmlId) {
@@ -297,52 +300,6 @@ function wysiwygData(data) {
}, data);
}
/**
* Create the wysiwyg instance for test (contains patch, usefull ir.ui.view, snippets).
*
* @param {object} params
*/
async function createWysiwyg(params) {
patch();
params.data = wysiwygData(params.data);
var parent = new Widget();
await testUtils.mock.addMockEnvironment(parent, params);
var wysiwygOptions = Object.assign({}, params.wysiwygOptions, {
recordInfo: {
context: {},
res_model: 'module.test',
res_id: 1,
},
useOnlyTestUnbreakable: params.useOnlyTestUnbreakable,
});
var wysiwyg = new WysiwygTest(parent, wysiwygOptions);
wysiwyg._parentToDestroyForTest = parent;
var $textarea = $('<textarea/>');
if (wysiwygOptions.value) {
$textarea.val(wysiwygOptions.value);
}
var selector = params.debug ? 'body' : '#qunit-fixture';
$textarea.prependTo($(selector));
if (params.debug) {
$('body').addClass('debug');
}
return wysiwyg.attachTo($textarea).then(function () {
if (wysiwygOptions.snippets) {
var defSnippets = testUtils.makeTestPromise();
testUtils.mock.intercept(wysiwyg, "snippets_loaded", function () {
defSnippets.resolve(wysiwyg);
});
return defSnippets;
}
return wysiwyg;
});
}
/**
* Char codes.
*/
@@ -882,7 +839,6 @@ function removeComments(html, removeMsoHide=true) {
export default {
wysiwygData: wysiwygData,
createWysiwyg: createWysiwyg,
testKeyboard: testKeyboard,
select: select,
keydown: keydown,
-10
View File
@@ -1,16 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="wysiwyg_iframe_editor_assets" name="Editor assets for wysiwyg iframe content" groups="base.group_user">
<t t-call-assets="web.assets_common" t-css="false" />
<t t-call-assets="web_editor.assets_wysiwyg" t-css="false" />
</template>
<template id="assets_edit_html_field" name="Wysiwyg Editor Assets for html field (style-inline)" groups="base.group_user,base.group_portal">
<t t-call-assets="web.assets_common" t-js="false"/>
<t t-call-assets="web_editor.assets_wysiwyg" t-js="false"/>
</template>
<!--
The web_editor.colorpicker template regroups section of colorpicker menu for website customization
(text foreground/background but also snippet background color option).
+5 -2
View File
@@ -205,6 +205,11 @@
'web._assets_frontend_helpers': [
('prepend', 'website/static/src/scss/bootstrap_overridden.scss'),
],
'web_editor.assets_wysiwyg': [
'website/static/src/js/editor/editor.js',
'/website/static/src/components/wysiwyg_adapter/toolbar_patch.js',
'website/static/src/xml/web_editor.xml',
],
'website.assets_wysiwyg': [
('include', 'web._assets_helpers'),
'web_editor/static/src/scss/bootstrap_overridden.scss',
@@ -212,7 +217,6 @@
'web/static/lib/bootstrap/scss/_variables.scss',
'website/static/src/scss/website.wysiwyg.scss',
'website/static/src/scss/website.edit_mode.scss',
'website/static/src/js/editor/editor.js',
'website/static/src/js/editor/snippets.editor.js',
'website/static/src/js/editor/snippets.options.js',
'website/static/src/snippets/s_facebook_page/options.js',
@@ -246,7 +250,6 @@
'website/static/src/snippets/s_searchbar/options.js',
'website/static/src/snippets/s_social_media/options.js',
'website/static/src/snippets/s_process_steps/options.js',
'website/static/src/js/editor/wysiwyg.js',
'website/static/src/js/editor/widget_link.js',
'website/static/src/js/widgets/link_popover_widget.js',
'website/static/src/xml/website.cookies_bar.xml',
@@ -3,11 +3,11 @@
import concurrency from 'web.concurrency';
import utils from 'web.utils';
import weUtils from 'web_editor.utils';
import {ColorpickerWidget} from 'web.Colorpicker';
import {_lt} from 'web.core';
import {svgToPNG} from 'website.utils';
import { useService } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { mixCssColors } from '@web/core/utils/colors';
const { Component, onMounted, reactive, useEnv, useRef, useState, useSubEnv, onWillStart, useExternalListener } = owl;
@@ -561,14 +561,14 @@ class Store {
setRecommendedPalette(color1, color2) {
if (color1 && color2) {
if (color1 === color2) {
color2 = ColorpickerWidget.mixCssColors('#FFFFFF', color1, 0.2);
color2 = mixCssColors('#FFFFFF', color1, 0.2);
}
const recommendedPalette = {
color1: color1,
color2: color2,
color3: ColorpickerWidget.mixCssColors('#FFFFFF', color2, 0.9),
color3: mixCssColors('#FFFFFF', color2, 0.9),
color4: '#FFFFFF',
color5: ColorpickerWidget.mixCssColors(color1, '#000000', 0.75),
color5: mixCssColors(color1, '#000000', 0.75),
};
CUSTOM_BG_COLOR_ATTRS.forEach((attr) => {
recommendedPalette[attr] = recommendedPalette[this.defaultColors[attr]];
@@ -43,8 +43,8 @@ export class MenuDialog extends Component {
useEffect(() => {
const $input = $(this.urlInputRef.el);
// This is only there to avoid changing the
// wUtils.autocompleteWithPages api
// wUtils.autocompleteWithPages rely on a widget that has a _rpc and
// trigger_up method.
const fakeWidget = {
_rpc: ({ route, params }) => this.rpc(route, params),
trigger_up: () => {
@@ -1,11 +1,9 @@
/** @odoo-module */
// Legacy services
import legacyEnv from 'web.commonEnv';
import { useService } from '@web/core/utils/hooks';
import { WysiwygAdapterComponent } from '../wysiwyg_adapter/wysiwyg_adapter';
import { useActiveElement } from '@web/core/ui/ui_service';
const { markup, Component, useState, useChildSubEnv, useEffect, onWillStart, onMounted, onWillUnmount } = owl;
const { markup, Component, useState, useEffect, onWillStart, onMounted, onWillUnmount } = owl;
export class WebsiteEditorComponent extends Component {
/**
@@ -30,10 +28,8 @@ export class WebsiteEditorComponent extends Component {
this.websiteService.invalidateSnippetCache = false;
}
useChildSubEnv(legacyEnv);
onWillStart(async () => {
this.Wysiwyg = await this.websiteService.loadWysiwyg();
await this.websiteService.loadWysiwyg();
});
useEffect(isPublicRootReady => {
@@ -7,13 +7,14 @@
<div t-if="state.showWysiwyg"
t-ref="wysiwyg-adapter"
t-att-class="{ o_w_wysiwyg_transition: !this.state.reloading, o_w_wysiwyg: this.websiteContext.snippetsLoaded }">
<WysiwygAdapterComponent Component="this.Wysiwyg"
<WysiwygAdapterComponent
editingValue="this.wysiwygOptions.value"
options="this.wysiwygOptions"
snippetSelector="this.reloadSelector"
willReload.bind="this.willReload"
reloadCallback.bind="this.reload"
quitCallback.bind="this.quit"
wysiwygReady.bind="this.wysiwygReady"
wysiwygOptions="this.wysiwygOptions"
editableElements="this.editableElements"
savableSelector="this.savableSelector"
beforeEditorActive="this.beforeEditorActive"
@@ -0,0 +1,13 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { Toolbar } from "@web_editor/js/editor/toolbar";
patch(Toolbar.props, 'toolbar_patch.js', {
...Toolbar.props,
showAnimateText: { type: Boolean, optional: true },
});
patch(Toolbar.defaultProps, 'toolbar_patch.js', {
...Toolbar.defaultProps,
showAnimateText: false,
});
@@ -1,11 +1,12 @@
/** @odoo-module */
import { ComponentAdapter } from 'web.OwlCompatibility';
import legacyEnv from 'web.commonEnv';
import { useWowlService } from '@web/legacy/utils';
import { useService, useBus } from "@web/core/utils/hooks";
import { useHotkey } from '@web/core/hotkeys/hotkey_hook';
import { Wysiwyg } from "@web_editor/js/wysiwyg/wysiwyg";
import { requireWysiwygLegacyModule } from "@web_editor/js/frontend/loader";
import { setEditableWindow } from 'web_editor.utils';
import { useBus } from "@web/core/utils/hooks";
import { EditMenuDialog, MenuDialog } from "../dialog/edit_menu";
import { WebsiteDialog } from '../dialog/dialog';
@@ -13,11 +14,54 @@ import { PageOption } from "./page_options";
const { onWillStart, useEffect, onWillUnmount } = owl;
/**
* Show/hide the dropdowns associated to the given toggles and allows to wait
* for when it is fully shown/hidden.
*
* Note: this also takes care of the fact the 'toggle' method of bootstrap does
* not properly work in all cases.
*
* @param {jQuery} $toggles
* @param {boolean} [show]
* @returns {Promise<jQuery>}
*/
function toggleDropdown($toggles, show) {
return Promise.all($($toggles).toArray().map(toggle => {
// We must select the element via the iframe so that the event handlers
// declared on the iframe are triggered.
const $toggle = toggle.ownerDocument.defaultView.$(toggle);
const shown = toggle.classList.contains('show');
if (shown === show) {
return;
}
const toShow = !shown;
return new Promise(resolve => {
$toggle.parent().one(
toShow ? 'shown.bs.dropdown' : 'hidden.bs.dropdown',
() => resolve()
);
$toggle.dropdown(toShow ? 'show' : 'hide');
});
})).then(() => $toggles);
}
/**
* This component adapts the Wysiwyg widget from @web_editor/wysiwyg.js.
* It encapsulate it so that this legacy widget can work in an OWL framework.
*/
export class WysiwygAdapterComponent extends ComponentAdapter {
export class WysiwygAdapterComponent extends Wysiwyg {
static props = {
...Wysiwyg.props,
snippetSelector: { type: String, optional: true },
willReload: { type: Function },
reloadCallback: { type: Function },
quitCallback: { type: Function },
wysiwygReady: { type: Function },
editableElements: { type: true, optional: true },
savableSelector: { type: String, optional: true },
beforeEditorActive: { type: Boolean, optional: true },
removeWelcomeMessage: { type: Function },
}
/**
* @override
*/
@@ -25,12 +69,12 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
super.setup();
this.options = this.props.options || {};
this.websiteService = useWowlService('website');
this.userService = useWowlService('user');
this.rpc = useWowlService('rpc');
this.orm = useWowlService('orm');
this.dialogs = useWowlService('dialog');
this.action = useWowlService('action');
this.websiteService = useService('website');
this.userService = useService('user');
this.rpc = useService('rpc');
this.orm = useService('orm');
this.dialogs = useService('dialog');
this.action = useService('action');
useBus(this.websiteService.bus, 'LEAVE-EDIT-MODE', (ev) => this.leaveEditMode(ev.detail));
@@ -66,39 +110,6 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
this.switchableRelatedViews = Promise.resolve(switchableRelatedViews);
});
useEffect(() => {
const initWysiwyg = async () => {
// Disable OdooEditor observer's while setting up classes
this.widget.odooEditor.observerUnactive();
this._addEditorMessages();
if (this.props.beforeEditorActive) {
await this.props.beforeEditorActive(this.$editable);
}
// The jquery instance inside the iframe needs to be aware of the wysiwyg.
this.websiteService.contentWindow.$('#wrapwrap').data('wysiwyg', this.widget);
await new Promise((resolve, reject) => this._websiteRootEvent('widgets_start_request', {
editableMode: true,
onSuccess: resolve,
onFailure: reject,
}));
if (this.props.snippetSelector) {
const $snippetEl = $(this.websiteService.pageDocument).find(this.props.snippetSelector);
await this.widget.snippetsMenu.activateSnippet($snippetEl);
if ($snippetEl.length) {
$snippetEl[0].scrollIntoView();
}
}
this.props.wysiwygReady();
// Wait for widgets to be destroyed and restarted before setting
// the dirty observer (not to be confused with odooEditor
// observer) as the widgets might trigger DOM mutations.
this._setObserver();
this.widget.odooEditor.observerActive();
};
initWysiwyg();
}, () => []);
useEffect(() => {
// Back navigation is handled with an additional state in the
// history, used to capture the popstate event.
@@ -135,9 +146,75 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
/**
* @override
*/
onWillStart() {
async startEdition() {
this.props.removeWelcomeMessage();
return super.onWillStart();
this.options.toolbarHandler = $('#web_editor-top-edit');
// Do not insert a paragraph after each column added by the column commands:
this.options.insertParagraphAfterColumns = false;
this.options.dropImageAsAttachment = true;
const $editableWindow = this.$editable[0].ownerDocument.defaultView;
// Dropdown menu initialization: handle dropdown openings by hand
var $dropdownMenuToggles = $editableWindow.$('.o_mega_menu_toggle, #top_menu_container .dropdown-toggle');
$dropdownMenuToggles.removeAttr('data-bs-toggle').dropdown('dispose');
$dropdownMenuToggles.on('click.wysiwyg_megamenu', ev => {
this.odooEditor.observerUnactive();
var $toggle = $(ev.currentTarget);
// Each time we toggle a dropdown, we will destroy the dropdown
// behavior afterwards to keep manual control of it
var dispose = ($els => $els.dropdown('dispose'));
// First hide all other dropdown menus
toggleDropdown($dropdownMenuToggles.not($toggle), false).then(dispose);
// Then toggle the clicked one
toggleDropdown($toggle)
.then(dispose)
.then(() => {
if (!this.options.enableTranslation) {
this._toggleMegaMenu($toggle[0]);
}
})
.then(() => this.odooEditor.observerActive());
});
// Ensure :blank oe_structure elements are in fact empty as ':blank'
// does not really work with all browsers.
for (const el of this.options.document.querySelectorAll('.oe_structure')) {
if (!el.innerHTML.trim()) {
$(el).empty();
}
}
await super.startEdition();
// Disable OdooEditor observer's while setting up classes
this.odooEditor.observerUnactive();
this._addEditorMessages();
if (this.props.beforeEditorActive) {
await this.props.beforeEditorActive(this.$editable);
}
// The jquery instance inside the iframe needs to be aware of the wysiwyg.
this.websiteService.contentWindow.$('#wrapwrap').data('wysiwyg', this);
await new Promise((resolve, reject) => this._websiteRootEvent('widgets_start_request', {
editableMode: true,
onSuccess: resolve,
onFailure: reject,
}));
if (this.props.snippetSelector) {
const $snippetEl = $(this.websiteService.pageDocument).find(this.props.snippetSelector);
await this.snippetsMenu.activateSnippet($snippetEl);
if ($snippetEl.length) {
$snippetEl[0].scrollIntoView();
}
}
this.props.wysiwygReady();
// Wait for widgets to be destroyed and restarted before setting
// the dirty observer (not to be confused with odooEditor
// observer) as the widgets might trigger DOM mutations.
this._setObserver();
this.odooEditor.observerActive();
}
/**
* Stop the widgets and save the content.
@@ -154,7 +231,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
await Promise.all(dirtyPageOptions.map(async ([name, option]) => {
await this.orm.write(mainObject.model, [mainObject.id], {[name]: option.value});
}));
return this.widget.saveContent(false);
return this.saveContent(false);
}
/**
* Returns the elements on the page which are editable.
@@ -180,12 +257,6 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
.not('[data-oe-sanitize-prevent-edition]')
.add('.o_editable');
}
/**
* @override
*/
get widgetArgs() {
return [this._wysiwygParams];
}
/**
* Return the editable parent element. This includes content inside it which isn't editable.
*
@@ -215,11 +286,8 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
leaveEditMode({ onLeave, forceLeave, onStay, reloadIframe = true } = {}) {
const leave = () => {
this.dummyWidgetEl = this._getDummmySnippetsEl();
this.widget.el.parentElement.appendChild(this.dummyWidgetEl);
this.el.parentElement.appendChild(this.dummyWidgetEl);
document.body.classList.add('editor_has_dummy_snippets');
// The wysiwyg is destroyed to avoid listeners from the OdooEditor
// and the SnippetsMenu to be triggered when reloading the iframe.
this.widget.destroy();
this.props.quitCallback({ onLeave, reloadIframe });
};
@@ -244,16 +312,39 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
leave();
}
}
/**
* @override
*/
async destroy() {
// We do not need the cache to live longer than the edition.
// Keeping it alive could end up in a corrupt state without the user
// even noticing. (If the values were changed in another tab or by
// someone else, when edit starts again here, without a clear cache at
// destroy, options will have wrong social media values).
// It would also survive (multi) website switch, not fetching the values
// from the accessed website.
const mod = await requireWysiwygLegacyModule('@website/snippets/s_social_media/options');
mod[Symbol.for('default')].clearDbSocialValuesCache();
const formOptionsMod = await requireWysiwygLegacyModule('@website/snippets/s_website_form/options');
formOptionsMod[Symbol.for('default')].clearAllFormsInfo();
this._restoreMegaMenus();
return super.destroy(...arguments);
}
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_renderElement() {
this.$root = this.$editable;
}
/**
* @private
* @return {Object} Params to pass to the wysiwyg widget.
*/
get _wysiwygParams() {
_getEditorOptions(options) {
const powerboxItems = this._getSnippetsPowerboxItems();
return {
snippets: 'website.snippets',
@@ -262,6 +353,11 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
data_res_model: 'website',
data_res_id: this._context.website_id,
},
toolbarOptions: {
...options.toolbarOptions,
showChecklist: false,
showAnimateText: true,
},
context: this._context,
editable: this.$editable,
discardButton: true,
@@ -281,7 +377,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
getReadOnlyAreas: this._getReadOnlyAreas.bind(this),
getUnremovableElements: this._getUnremovableElements.bind(this),
direction: this.websiteService.currentWebsite.metadata.direction,
...this.props.wysiwygOptions,
...options,
};
}
/**
@@ -293,11 +389,11 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
*/
_setObserver() {
const processRecords = (records) => {
records = this.widget.odooEditor.filterMutationRecords(records);
records = this.odooEditor.filterMutationRecords(records);
// Skip the step for this stack because if the editor undo the first
// step that has a dirty element, the following code would have
// generated a new stack and break the "redo" of the editor.
this.widget.odooEditor.automaticStepSkipStack();
this.odooEditor.automaticStepSkipStack();
for (const record of records) {
const $savable = $(record.target).closest(this.savableSelector);
@@ -315,13 +411,13 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
this._observe();
this.widget.odooEditor.addEventListener('observerUnactive', () => {
this.odooEditor.addEventListener('observerUnactive', () => {
if (this.observer) {
processRecords(this.observer.takeRecords());
this.observer.disconnect();
}
});
this.widget.odooEditor.addEventListener('observerActive', this._observe.bind(this));
this.odooEditor.addEventListener('observerActive', this._observe.bind(this));
}
/**
* @private
@@ -406,23 +502,6 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
_getUnremovableElements() {
return [];
}
/**
* This method provides support for the legacy event system.
* The supported events are defined in the prototype.
* @see WysiwygAdapterComponent.events
* If the event is not supported it uses the super class method's.
* See {@link ComponentAdapter._trigger_up}.
*
* @override
* @param {Event} event
*/
_trigger_up(event) {
if (this.events[event.name]) {
let method = this.events[event.name];
return this[method](event);
}
return super._trigger_up(...arguments);
}
/***
* Handles action request from inner widgets
*
@@ -443,7 +522,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
return this.dialogs.add(EditMenuDialog, {
rootID: params[0],
save: () => {
const snippetsMenu = this.widget.snippetsMenu;
const snippetsMenu = this.snippetsMenu;
snippetsMenu.trigger_up('request_save', {reload: true, _toMutex: true});
},
});
@@ -534,11 +613,11 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
const snippetCommandCallback = (selector) => {
const $separatorBody = $(selector);
const $clonedBody = $separatorBody.clone().removeClass('oe_snippet_body');
const range = this.widget.getDeepRange();
const block = this.widget.closestElement(range.endContainer, 'p, div, ol, ul, cl, h1, h2, h3, h4, h5, h6');
const range = this.getDeepRange();
const block = this.closestElement(range.endContainer, 'p, div, ol, ul, cl, h1, h2, h3, h4, h5, h6');
if (block) {
block.after($clonedBody[0]);
this.widget.snippetsMenu.callPostSnippetDrop($clonedBody);
this.snippetsMenu.callPostSnippetDrop($clonedBody);
}
};
const commands = [
@@ -548,7 +627,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 100,
description: this.env._t('Insert an alert snippet'),
fontawesome: 'fa-info',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_alert"]');
},
@@ -559,7 +638,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 90,
description: this.env._t('Insert a rating snippet'),
fontawesome: 'fa-star-half-o',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_rating"]');
},
@@ -570,7 +649,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 80,
description: this.env._t('Insert a card snippet'),
fontawesome: 'fa-sticky-note',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_card"]');
},
@@ -581,7 +660,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 70,
description: this.env._t('Insert a share snippet'),
fontawesome: 'fa-share-square-o',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_share"]');
},
@@ -592,7 +671,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 60,
description: this.env._t('Insert a text Highlight snippet'),
fontawesome: 'fa-sticky-note',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_text_highlight"]');
},
@@ -603,7 +682,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 50,
description: this.env._t('Insert a chart snippet'),
fontawesome: 'fa-bar-chart',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_chart"]');
},
@@ -614,7 +693,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 40,
description: this.env._t('Insert a progress bar snippet'),
fontawesome: 'fa-spinner',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_progress_bar"]');
},
@@ -625,7 +704,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 30,
description: this.env._t('Insert a badge snippet'),
fontawesome: 'fa-tags',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_badge"]');
},
@@ -636,7 +715,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 20,
description: this.env._t('Insert a blockquote snippet'),
fontawesome: 'fa-quote-left',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_blockquote"]');
},
@@ -647,7 +726,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
priority: 10,
description: this.env._t('Insert an horizontal separator snippet'),
fontawesome: 'fa-minus',
isDisabled: () => !this.widget.odooEditor.isSelectionInBlockRoot(),
isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(),
callback: () => {
snippetCommandCallback('.oe_snippet_body[data-snippet="s_hr"]');
},
@@ -659,7 +738,193 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
* @returns {boolean} true if the page has been altered.
*/
_isDirty() {
return this.widget.isDirty() || Object.values(this.pageOptions).some(option => option.isDirty);
return this.isDirty() || Object.values(this.pageOptions).some(option => option.isDirty);
}
_trigger_up(ev) {
const triggers = {
'widgets_start_request': this._onRootEventRequest.bind(this),
'widgets_stop_request': this._onRootEventRequest.bind(this),
'ready_to_clean_for_save': this._onRootEventRequest.bind(this),
'will_remove_snippet': this._onRootEventRequest.bind(this),
'gmap_api_request': this._onRootEventRequest.bind(this),
'gmap_api_key_request': this._onRootEventRequest.bind(this),
'request_save': this._onSaveRequest.bind(this),
'context_get': this._onContextGet.bind(this),
'service_context_get': this._onServiceContextGet.bind(this),
'action_demand': this._handleAction.bind(this),
'request_cancel': this._onCancelRequest.bind(this),
'snippet_will_be_cloned': this._onSnippetWillBeCloned.bind(this),
'snippet_cloned': this._onSnippetCloned.bind(this),
'snippet_dropped': this._onSnippetDropped.bind(this),
'snippet_removed': this._onSnippetRemoved.bind(this),
'reload_bundles': this._reloadBundles.bind(this),
'menu_dialog': this._onMenuDialogRequest.bind(this),
'update_color_previews': this._onColorPreviewsUpdate.bind(this),
'request_mobile_preview': this._onMobilePreviewRequest.bind(this),
'get_switchable_related_views': this._onGetSwitchableRelatedViews.bind(this),
};
const evType = ev.name;
const payload = ev.data;
if (evType in triggers) {
triggers[evType](ev);
} else if (evType === 'call_service') {
let args = payload.args || [];
if (payload.service === 'ajax' && payload.method === 'rpc') {
// ajax service uses an extra 'target' argument for rpc
args = args.concat(ev.target);
}
const service = legacyEnv.services[payload.service];
//If the service doesn't exist it means that it was translated to Owl
if (service) {
const result = service[payload.method].apply(service, args);
payload.callback(result);
} else {
throw new Error(
`The service "${payload.service}" is not present in the legacy owl environment.
You should probably create a mapper in @web/legacy/utils`
);
}
} else {
super._trigger_up(...arguments);
}
}
/**
* @override
* @returns {Promise}
*/
async _saveViewBlocks() {
await super._saveViewBlocks(...arguments);
if (this.isDirty()) {
return this._restoreMegaMenus();
}
}
/**
* @private
* @param {HTMLElement} editable
*/
_saveCoverProperties($elementToSave) {
var el = $elementToSave.closest('.o_record_cover_container')[0];
if (!el) {
return;
}
var resModel = el.dataset.resModel;
var resID = parseInt(el.dataset.resId);
if (!resModel || !resID) {
throw new Error('There should be a model and id associated to the cover');
}
// The cover might be dirty for another reason than cover properties
// values only (like an editable text inside). In that case, do not
// update the cover properties values.
if (!('coverClass' in el.dataset)) {
return;
}
this.__savedCovers = this.__savedCovers || {};
this.__savedCovers[resModel] = this.__savedCovers[resModel] || [];
if (this.__savedCovers[resModel].includes(resID)) {
return;
}
this.__savedCovers[resModel].push(resID);
var cssBgImage = $(el.querySelector('.o_record_cover_image')).css('background-image');
var coverProps = {
'background-image': cssBgImage.replace(/"/g, '').replace(window.location.protocol + "//" + window.location.host, ''),
'background_color_class': el.dataset.bgColorClass,
'background_color_style': el.dataset.bgColorStyle,
'opacity': el.dataset.filterValue,
'resize_class': el.dataset.coverClass,
'text_align_class': el.dataset.textAlignClass,
};
return this.orm.write(resModel, [resID], {'cover_properties': JSON.stringify(coverProps)});
}
/**
*
* @override
*/
async _createSnippetsMenuInstance(options = {}) {
const snippetsEditor = await requireWysiwygLegacyModule('@website/js/editor/snippets.editor');
const { SnippetsMenu } = snippetsEditor[Symbol.for("default")];
return new SnippetsMenu(this, Object.assign({
wysiwyg: this,
selectorEditableArea: '.o_editable',
}, options));
}
/**
* @override
*/
_insertSnippetMenu() {
return this.snippetsMenu.appendTo(this.$el);
}
/**
* @override
*/
async _saveElement($el, context, withLang) {
var promises = [];
// Saving a view content
await super._saveElement(...arguments);
// Saving mega menu options
if ($el.data('oe-field') === 'mega_menu_content') {
// On top of saving the mega menu content like any other field
// content, we must save the custom classes that were set on the
// menu itself.
// FIXME normally removing the 'show' class should not be necessary here
// TODO check that editor classes are removed here as well
var classes = $el
.attr("class")
.split(" ")
.filter((attr) => {
["dropdown-menu", "o_mega_menu", "show"].indexOf(attr) < 0;
});
promises.push(
this.orm.write('website.menu', [parseInt($el.data('oe-id'))], {
'mega_menu_classes': classes.join(' '),
})
);
}
// Saving cover properties on related model if any
var prom = this._saveCoverProperties($el);
if (prom) {
promises.push(prom);
}
return Promise.all(promises);
}
/**
* Restores mega menu behaviors and closes them (important to do before
* saving otherwise they would be saved opened).
*
* @private
* @returns {Promise}
*/
_restoreMegaMenus() {
var $megaMenuToggles = this.$editable.find('.o_mega_menu_toggle');
$megaMenuToggles.off('.wysiwyg_megamenu')
.attr('data-bs-toggle', 'dropdown')
.dropdown({});
return toggleDropdown($megaMenuToggles, false);
}
/**
* Toggles the mega menu.
*
* @private
* @returns {Promise}
*/
_toggleMegaMenu(toggleEl) {
const megaMenuEl = toggleEl.parentElement.querySelector('.o_mega_menu');
if (!megaMenuEl || !megaMenuEl.classList.contains('show')) {
return this.snippetsMenu.activateSnippet(false);
}
megaMenuEl.classList.add('o_no_parent_editor');
return this.snippetsMenu.activateSnippet($(megaMenuEl));
}
//--------------------------------------------------------------------------
@@ -692,7 +957,6 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
async _onSaveRequest(event) {
let callback = () => this.leaveEditMode({ forceLeave: true });
if (event.data.reload || event.data.reloadEditor) {
this.widget.trigger_up('disable_loading_effects');
this.props.willReload(this._getDummmySnippetsEl());
callback = async () => {
if (event.data.onSuccess) {
@@ -841,7 +1105,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
* @private
*/
_onColorPreviewsUpdate() {
this.widget.setCSSVariables(this.widget.snippetsMenu.el);
this.setCSSVariables(this.snippetsMenu.el);
}
/**
* Update the context to trigger a mobile view.
@@ -870,7 +1134,7 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
* so that they are not triggered during a reload).
*/
_getDummmySnippetsEl() {
const dummySnippetsEl = this.widget.el.cloneNode(true);
const dummySnippetsEl = this.el.cloneNode(true);
dummySnippetsEl.querySelectorAll('#oe_manipulators, .d-none, .oe_snippet_body').forEach(el => el.remove());
dummySnippetsEl.querySelectorAll('we-input input').forEach(input => {
input.setAttribute('value', input.closest('we-input').dataset.selectStyle || '');
@@ -878,25 +1142,3 @@ export class WysiwygAdapterComponent extends ComponentAdapter {
return dummySnippetsEl;
}
}
WysiwygAdapterComponent.prototype.events = {
'widgets_start_request': '_onRootEventRequest',
'widgets_stop_request': '_onRootEventRequest',
'ready_to_clean_for_save': '_onRootEventRequest',
'will_remove_snippet': '_onRootEventRequest',
'gmap_api_request': '_onRootEventRequest',
'gmap_api_key_request': '_onRootEventRequest',
'request_save': '_onSaveRequest',
'context_get': '_onContextGet',
'service_context_get': '_onServiceContextGet',
'action_demand': '_handleAction',
'request_cancel': '_onCancelRequest',
'snippet_will_be_cloned': '_onSnippetWillBeCloned',
'snippet_cloned': '_onSnippetCloned',
'snippet_dropped': '_onSnippetDropped',
'snippet_removed': '_onSnippetRemoved',
'reload_bundles': '_reloadBundles',
'menu_dialog': '_onMenuDialogRequest',
'update_color_previews': '_onColorPreviewsUpdate',
'request_mobile_preview': '_onMobilePreviewRequest',
'get_switchable_related_views': '_onGetSwitchableRelatedViews',
};
@@ -2,13 +2,13 @@
import { createPublicRoot } from "@web/legacy/js/public/public_root";
import { WebsiteRoot } from "./website_root";
import { loadWysiwyg } from "web_editor.loader";
import { loadLegacyWysiwygAssets } from "@web_editor/js/frontend/loader";
export default createPublicRoot(WebsiteRoot).then(rootInstance => {
// This data attribute is set by the WebsitePreview client action for a
// restricted editor user.
if (window.frameElement && window.frameElement.dataset.loadWysiwyg === 'true') {
loadWysiwyg(['website.assets_wysiwyg']).then(() => {
loadLegacyWysiwygAssets(['web_editor.assets_wysiwyg', 'website.assets_wysiwyg']).then(() => {
window.dispatchEvent(new CustomEvent('PUBLIC-ROOT-READY', {detail: {rootInstance}}));
});
}
+17 -5
View File
@@ -1,9 +1,15 @@
/** @odoo-module **/
import weWidgets from "web_editor.widget";
import wUtils from "website.utils";
import { LinkDialog } from "@web_editor/js/wysiwyg/widgets/link_dialog";
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import wUtils from 'website.utils';
weWidgets.LinkDialog.include({
patch(LinkDialog.prototype, "editor.js", {
setup() {
this._super(...arguments);
this.rpc = useService("rpc");
},
/**
* Allows the URL input to propose existing website pages.
*
@@ -11,10 +17,16 @@ weWidgets.LinkDialog.include({
*/
start: async function () {
const options = {
body: this.linkWidget.$link && this.linkWidget.$link[0].ownerDocument.body,
body: this.$link && this.$link[0].ownerDocument.body,
};
const result = await this._super.apply(this, arguments);
wUtils.autocompleteWithPages(this, this.$('input[name="url"]'), options);
// wUtils.autocompleteWithPages rely on a widget that has a _rpc and
// trigger_up method.
const fakeWidget = {
_rpc: ({ route, params }) => this.rpc(route, params),
trigger_up: () => {},
};
wUtils.autocompleteWithPages(fakeWidget, this.$el.find('input[name="url"]'), options);
return result;
},
});
@@ -517,6 +517,89 @@ weSnippetEditor.SnippetEditor.include({
return restore;
},
});
wSnippetMenu.include({
/**
* @override
*/
init: function () {
this._super(...arguments);
this._notActivableElementsSelector += ', .o_mega_menu_toggle';
},
/**
* @override
*/
start() {
const _super = this._super(...arguments);
if (this.options.enableTranslation) {
return _super;
}
if (this.$body[0].ownerDocument !== this.ownerDocument) {
this.$body.on('click.snippets_menu', '*', this._onClick);
}
return _super;
},
/**
* @override
*/
destroy() {
if (this.$body[0].ownerDocument !== this.ownerDocument) {
this.$body.off('.snippets_menu');
}
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
async cleanForSave() {
const getFromEditable = selector => this.options.editable[0].querySelectorAll(selector);
// Clean unstyled translations
return this._super(...arguments).then(() => {
for (const el of getFromEditable('.o_translation_without_style')) {
el.classList.remove('o_translation_without_style');
if (el.dataset.oeTranslationSaveSha) {
el.dataset.oeTranslationInitialSha = el.dataset.oeTranslationSaveSha;
delete el.dataset.oeTranslationSaveSha;
}
}
// Adapt translation values for `select` > `options`s and remove all
// temporary `.o_translation_select` elements.
for (const optionsEl of getFromEditable('.o_translation_select')) {
const selectEl = optionsEl.nextElementSibling;
const translatedOptions = optionsEl.children;
const selectOptions = selectEl.tagName === 'SELECT' ? [...selectEl.options] : [];
if (selectOptions.length === translatedOptions.length) {
selectOptions.map((option, i) => {
option.text = translatedOptions[i].textContent;
});
}
optionsEl.remove();
}
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_insertDropzone: function ($hook) {
var $hookParent = $hook.parent();
var $dropzone = this._super(...arguments);
$dropzone.attr('data-editor-message-default', $hookParent.attr('data-editor-message-default'));
$dropzone.attr('data-editor-message', $hookParent.attr('data-editor-message'));
$dropzone.attr('data-editor-sub-message', $hookParent.attr('data-editor-sub-message'));
return $dropzone;
},
});
export default {
SnippetsMenu: wSnippetMenu,
};
@@ -1,18 +1,24 @@
/** @odoo-module alias=website.editor.snippets.options **/
import {ColorpickerWidget} from "web.Colorpicker";
import core from "web.core";
import { loadBundle, loadCSS } from "@web/core/assets";
import Dialog from "web.Dialog";
import {Markup, sprintf} from "web.utils";
import weUtils from "web_editor.utils";
import options from "web_editor.snippets.options";
import wLinkPopoverWidget from "@website/js/widgets/link_popover_widget";
import { NavbarLinkPopoverWidget } from "@website/js/widgets/link_popover_widget";
import wUtils from "website.utils";
import {isImageSupportedForStyle} from "web_editor.image_processing";
import "website.s_popup_options";
import { range } from "@web/core/utils/numbers";
import {Domain} from "@web/core/domain";
import {
isCSSColor,
convertCSSColorToRgba,
convertRgbaToCSSColor,
convertRgbToHsl,
convertHslToRgb,
} from '@web/core/utils/colors';
var _t = core._t;
var qweb = core.qweb;
@@ -798,7 +804,7 @@ options.Class.include({
if (color) {
if (weUtils.isColorCombinationName(color)) {
finalColors[colorName] = parseInt(color);
} else if (!ColorpickerWidget.isCSSColor(color)) {
} else if (!isCSSColor(color)) {
finalColors[colorName] = `'${color}'`;
}
}
@@ -1240,12 +1246,12 @@ options.registry.OptionsTab = options.Class.extend({
const baseStyle = getComputedStyle(document.documentElement);
for (let id = 100; id <= 900; id += 100) {
const gray = weUtils.getCSSVariableValue(`${id}`, style);
const grayRGB = ColorpickerWidget.convertCSSColorToRgba(gray);
const grayHSL = ColorpickerWidget.convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue);
const grayRGB = convertCSSColorToRgba(gray);
const grayHSL = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue);
const baseGray = weUtils.getCSSVariableValue(`base-${id}`, baseStyle);
const baseGrayRGB = ColorpickerWidget.convertCSSColorToRgba(baseGray);
const baseGrayHSL = ColorpickerWidget.convertRgbToHsl(baseGrayRGB.red, baseGrayRGB.green, baseGrayRGB.blue);
const baseGrayRGB = convertCSSColorToRgba(baseGray);
const baseGrayHSL = convertRgbToHsl(baseGrayRGB.red, baseGrayRGB.green, baseGrayRGB.blue);
if (grayHSL.saturation > 0.01) {
if (grayHSL.lightness > 0.01 && grayHSL.lightness < 99.99) {
@@ -1466,12 +1472,12 @@ options.registry.OptionsTab = options.Class.extend({
_buildGray(id) {
// Getting base grays defined in color_palette.scss
const gray = weUtils.getCSSVariableValue(`base-${id}`, getComputedStyle(document.documentElement));
const grayRGB = ColorpickerWidget.convertCSSColorToRgba(gray);
const hsl = ColorpickerWidget.convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue);
const adjustedGrayRGB = ColorpickerWidget.convertHslToRgb(this.grayParams[this.GRAY_PARAMS.HUE],
const grayRGB = convertCSSColorToRgba(gray);
const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue);
const adjustedGrayRGB = convertHslToRgb(this.grayParams[this.GRAY_PARAMS.HUE],
Math.min(Math.max(hsl.saturation + this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION], 0), 100),
hsl.lightness);
return ColorpickerWidget.convertRgbaToCSSColor(adjustedGrayRGB.red, adjustedGrayRGB.green, adjustedGrayRGB.blue);
return convertRgbaToCSSColor(adjustedGrayRGB.red, adjustedGrayRGB.green, adjustedGrayRGB.blue);
},
/**
* @override
@@ -1482,8 +1488,8 @@ options.registry.OptionsTab = options.Class.extend({
if (extraSaturationRangeEl) {
const baseGrays = range(100, 1000, 100).map(id => {
const gray = weUtils.getCSSVariableValue(`base-${id}`);
const grayRGB = ColorpickerWidget.convertCSSColorToRgba(gray);
const hsl = ColorpickerWidget.convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue);
const grayRGB = convertCSSColorToRgba(gray);
const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue);
return {id: id, hsl: hsl};
});
const first = baseGrays[0];
@@ -1669,7 +1675,7 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({
let ccPreviewEls = [];
for (let i = 1; i <= 5; i++) {
const collapseEl = document.createElement('we-collapse');
const ccPreviewEl = $(qweb.render('web_editor.color.combination.preview'))[0];
const ccPreviewEl = $(qweb.render('web_editor.color.combination.preview.legacy'))[0];
ccPreviewEl.classList.add('text-center', `o_cc${i}`, 'o_colored_level', 'o_we_collapse_toggler');
collapseEl.appendChild(ccPreviewEl);
const editionEls = $(qweb.render('website.color_combination_edition', {number: i}));
@@ -1697,7 +1703,54 @@ options.registry.menu_data = options.Class.extend({
start: function () {
const wysiwyg = $(this.ownerDocument.getElementById('wrapwrap')).data('wysiwyg');
const popoverContainer = this.ownerDocument.getElementById('oe_manipulators');
wLinkPopoverWidget.createFor(this, this.$target[0], { wysiwyg, container: popoverContainer });
NavbarLinkPopoverWidget.createFor({
target: this.$target[0],
wysiwyg,
container: popoverContainer,
notify: this.displayNotification.bind(this),
checkIsWebsiteDesigner: () => this._rpc({
'model': 'res.users',
'method': 'has_group',
'args': ['website.group_website_designer'],
}),
onEditLinkClick: (widget) => {
var $menu = widget.$target.find('[data-oe-id]');
this.trigger_up('menu_dialog', {
name: $menu.text(),
url: $menu.parent().attr('href'),
save: (name, url) => {
let websiteId;
this.trigger_up('context_get', {
callback: ctx => websiteId = ctx['website_id'],
});
const data = {
id: $menu.data('oe-id'),
name,
url,
};
return this._rpc({
model: 'website.menu',
method: 'save',
args: [websiteId, {'data': [data]}],
}).then(function () {
widget.wysiwyg.odooEditor.observerUnactive();
widget.$target.attr('href', url);
$menu.text(name);
widget.wysiwyg.odooEditor.observerActive();
});
},
});
widget.popover.hide();
},
onEditMenuClick: (widget) => {
const contentMenu = widget.target.closest('[data-content_menu_id]');
const rootID = contentMenu ? parseInt(contentMenu.dataset.content_menu_id, 10) : undefined;
this.trigger_up('action_demand', {
actionName: 'edit_menu',
params: [rootID],
});
},
});
return this._super(...arguments);
},
/**
@@ -3111,16 +3164,16 @@ options.registry.CoverProperties = options.Class.extend({
const ccValue = colorPickerWidget._ccValue;
const colorOrGradient = colorPickerWidget._value;
const isGradient = weUtils.isColorGradient(colorOrGradient);
const isCSSColor = !isGradient && ColorpickerWidget.isCSSColor(colorOrGradient);
const valueIsCSSColor = !isGradient && isCSSColor(colorOrGradient);
const colorNames = [];
if (ccValue) {
colorNames.push(ccValue);
}
if (colorOrGradient && !isGradient && !isCSSColor) {
if (colorOrGradient && !isGradient && !valueIsCSSColor) {
colorNames.push(colorOrGradient);
}
const bgColorClass = weUtils.computeColorClasses(colorNames).join(' ');
const bgColorStyle = isCSSColor ? `background-color: ${colorOrGradient};` :
const bgColorStyle = valueIsCSSColor ? `background-color: ${colorOrGradient};` :
isGradient ? `background-color: rgba(0, 0, 0, 0); background-image: ${colorOrGradient};` : '';
this._updateColorDataset(bgColorStyle, bgColorClass);
},
@@ -3480,8 +3533,7 @@ options.registry.WebsiteAnimate = options.Class.extend({
if (this.isAnimatedText) {
// For animated text, the animation options must be in the editor
// toolbar.
const $toolbar = this.options.wysiwyg.toolbar.$el;
$toolbar.append(this.$el);
this.options.wysiwyg.toolbarEl.append(this.$el[0]);
this.$optionsSection.addClass('d-none');
}
},
@@ -1,28 +1,34 @@
/** @odoo-module **/
import weWidgets from "wysiwyg.widgets";
import { LinkTools } from '@web_editor/js/wysiwyg/widgets/link_tools';
import { patch } from "@web/core/utils/patch";
import { useService } from "@web/core/utils/hooks";
import wUtils from "website.utils";
import { debounce } from "@web/core/utils/timing";
weWidgets.LinkTools.include({
custom_events: Object.assign({}, weWidgets.LinkTools.prototype.custom_events || {}, {
website_url_chosen: '_onAutocompleteClose',
}),
LINK_DEBOUNCE: 1000,
const LINK_DEBOUNCE = 1000;
patch(LinkTools.prototype, 'website/static/src/js/editor/widget_link.js', {
setup() {
this.rpc = useService('rpc');
this.rpc = this.env.services.rpc;
return this._super.apply(this, arguments);
},
/**
* @constructor
*
* @override
*/
init: function () {
this._super.apply(this, arguments);
this._adaptPageAnchor = debounce(this._adaptPageAnchor, this.LINK_DEBOUNCE);
onWillStart() {
this._adaptPageAnchor = debounce(this._adaptPageAnchor, LINK_DEBOUNCE);
return this._super.apply(this, arguments);
},
/**
* Allows the URL input to propose existing website pages.
*
* @override
*/
start: async function () {
async start() {
var def = await this._super.apply(this, arguments);
const options = {
position: {
@@ -33,7 +39,13 @@ weWidgets.LinkTools.include({
},
body: this.$editable[0].ownerDocument.body,
};
wUtils.autocompleteWithPages(this, this.$('input[name="url"]'), options);
// wUtils.autocompleteWithPages rely on a widget that has a _rpc and
// trigger_up method.
const fakeWidget = {
_rpc: ({ route, params }) => this.rpc(route, params),
trigger_up: this._onAutocompleteClose.bind(this),
};
wUtils.autocompleteWithPages(fakeWidget, this.$el.find('input[name="url"]'), options);
this._adaptPageAnchor();
return def;
},
@@ -45,11 +57,11 @@ weWidgets.LinkTools.include({
/**
* @private
*/
_adaptPageAnchor: function () {
const urlInputValue = this.$('input[name="url"]').val();
const $pageAnchor = this.$('.o_link_dialog_page_anchor');
_adaptPageAnchor() {
const urlInputValue = this.$el.find('input[name="url"]').val();
const $pageAnchor = this.$el.find('.o_link_dialog_page_anchor');
const isFromWebsite = urlInputValue[0] === '/';
const $selectMenu = this.$('we-selection-items[name="link_anchor"]');
const $selectMenu = this.$el.find('we-selection-items[name="link_anchor"]');
if ($selectMenu.data("anchor-for") !== urlInputValue) { // avoid useless query
$pageAnchor.toggleClass('d-none', !isFromWebsite);
@@ -86,13 +98,13 @@ weWidgets.LinkTools.include({
/**
* @private
*/
_onAutocompleteClose: function () {
_onAutocompleteClose() {
this._onURLInput();
},
/**
* @override
*/
_onURLInput: function () {
_onURLInput() {
this._super.apply(this, arguments);
this._adaptPageAnchor();
},
@@ -103,7 +115,7 @@ weWidgets.LinkTools.include({
_onPickSelectOption(ev) {
if (ev.currentTarget.closest('[name="link_anchor"]')) {
const anchorValue = $(ev.currentTarget).data('value');
const $urlInput = this.$('[name="url"]');
const $urlInput = this.$el.find('[name="url"]');
let urlInputValue = $urlInput.val();
if (urlInputValue.indexOf('#') > -1) {
urlInputValue = urlInputValue.substr(0, urlInputValue.indexOf('#'));
@@ -1,362 +0,0 @@
/** @odoo-module alias=website.wysiwyg **/
import Wysiwyg from "web_editor.wysiwyg";
import snippetsEditor from "website.snippet.editor";
import socialMediaOptions from "@website/snippets/s_social_media/options";
import formOptions from "@website/snippets/s_website_form/options";
/**
* Show/hide the dropdowns associated to the given toggles and allows to wait
* for when it is fully shown/hidden.
*
* Note: this also takes care of the fact the 'toggle' method of bootstrap does
* not properly work in all cases.
*
* @param {jQuery} $toggles
* @param {boolean} [show]
* @returns {Promise<jQuery>}
*/
function toggleDropdown($toggles, show) {
return Promise.all($($toggles).toArray().map(toggle => {
// We must select the element via the iframe so that the event handlers
// declared on the iframe are triggered.
const $toggle = toggle.ownerDocument.defaultView.$(toggle);
const shown = toggle.classList.contains('show');
if (shown === show) {
return;
}
const toShow = !shown;
return new Promise(resolve => {
$toggle.parent().one(
toShow ? 'shown.bs.dropdown' : 'hidden.bs.dropdown',
() => resolve()
);
$toggle.dropdown(toShow ? 'show' : 'hide');
});
})).then(() => $toggles);
}
/**
* HtmlEditor
* Intended to edit HTML content. This widget uses the Wysiwyg editor
* improved by odoo.
*
* class editable: o_editable
* class non editable: o_not_editable
*
*/
const WebsiteWysiwyg = Wysiwyg.extend({
/**
* @override
*/
start: function () {
this.options.toolbarHandler = $('#web_editor-top-edit');
// Do not insert a paragraph after each column added by the column commands:
this.options.insertParagraphAfterColumns = false;
this.options.dropImageAsAttachment = true;
const $editableWindow = this.$editable[0].ownerDocument.defaultView;
// Dropdown menu initialization: handle dropdown openings by hand
var $dropdownMenuToggles = $editableWindow.$('.o_mega_menu_toggle, #top_menu_container .dropdown-toggle');
$dropdownMenuToggles.removeAttr('data-bs-toggle').dropdown('dispose');
$dropdownMenuToggles.on('click.wysiwyg_megamenu', ev => {
this.odooEditor.observerUnactive();
var $toggle = $(ev.currentTarget);
// Each time we toggle a dropdown, we will destroy the dropdown
// behavior afterwards to keep manual control of it
var dispose = ($els => $els.dropdown('dispose'));
// First hide all other dropdown menus
toggleDropdown($dropdownMenuToggles.not($toggle), false).then(dispose);
// Then toggle the clicked one
toggleDropdown($toggle)
.then(dispose)
.then(() => {
if (!this.options.enableTranslation) {
this._toggleMegaMenu($toggle[0]);
}
})
.then(() => this.odooEditor.observerActive());
});
// Ensure :blank oe_structure elements are in fact empty as ':blank'
// does not really work with all browsers.
for (const el of this.$('.oe_structure')) {
if (!el.innerHTML.trim()) {
$(el).empty();
}
}
return this._super.apply(this, arguments);
},
/**
* @override
* @returns {Promise}
*/
_saveViewBlocks: async function () {
await this._super.apply(this, arguments);
if (this.isDirty()) {
return this._restoreMegaMenus();
}
},
/**
* @override
*/
destroy: function () {
// We do not need the cache to live longer than the edition.
// Keeping it alive could end up in a corrupt state without the user
// even noticing. (If the values were changed in another tab or by
// someone else, when edit starts again here, without a clear cache at
// destroy, options will have wrong social media values).
// It would also survive (multi) website switch, not fetching the values
// from the accessed website.
socialMediaOptions.clearDbSocialValuesCache();
formOptions.clearAllFormsInfo();
this._restoreMegaMenus();
this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {HTMLElement} editable
*/
_saveCoverProperties: function ($elementToSave) {
var el = $elementToSave.closest('.o_record_cover_container')[0];
if (!el) {
return;
}
var resModel = el.dataset.resModel;
var resID = parseInt(el.dataset.resId);
if (!resModel || !resID) {
throw new Error('There should be a model and id associated to the cover');
}
// The cover might be dirty for another reason than cover properties
// values only (like an editable text inside). In that case, do not
// update the cover properties values.
if (!('coverClass' in el.dataset)) {
return;
}
this.__savedCovers = this.__savedCovers || {};
this.__savedCovers[resModel] = this.__savedCovers[resModel] || [];
if (this.__savedCovers[resModel].includes(resID)) {
return;
}
this.__savedCovers[resModel].push(resID);
var cssBgImage = $(el.querySelector('.o_record_cover_image')).css('background-image');
var coverProps = {
'background-image': cssBgImage.replace(/"/g, '').replace(window.location.protocol + "//" + window.location.host, ''),
'background_color_class': el.dataset.bgColorClass,
'background_color_style': el.dataset.bgColorStyle,
'opacity': el.dataset.filterValue,
'resize_class': el.dataset.coverClass,
'text_align_class': el.dataset.textAlignClass,
};
return this._rpc({
model: resModel,
method: 'write',
args: [
resID,
{'cover_properties': JSON.stringify(coverProps)}
],
});
},
/**
* @override
*/
_rpc(options) {
// Historically, every RPC had their website_id in their context.
// Now it's something defined by the wysiwyg_adapter.
// So in order to have a full context, we request it from the wysiwyg_adapter.
let context;
this.trigger_up('context_get', {
callback: cxt => context = cxt,
});
context = Object.assign(context, options.context);
options.context = context;
return this._super(options);
},
/**
*
* @override
*/
_createSnippetsMenuInstance(options = {}) {
return new snippetsEditor.SnippetsMenu(this, Object.assign({
wysiwyg: this,
selectorEditableArea: '.o_editable',
}, options));
},
/**
* @override
*/
_insertSnippetMenu() {
return this.snippetsMenu.appendTo(this.$el);
},
/**
* @override
*/
_saveElement: async function ($el, context, withLang) {
var promises = [];
// Saving a view content
await this._super.apply(this, arguments);
// Saving mega menu options
if ($el.data('oe-field') === 'mega_menu_content') {
// On top of saving the mega menu content like any other field
// content, we must save the custom classes that were set on the
// menu itself.
// FIXME normally removing the 'show' class should not be necessary here
// TODO check that editor classes are removed here as well
var classes = $el
.attr("class")
.split(" ")
.filter((attr) => {
["dropdown-menu", "o_mega_menu", "show"].indexOf(attr) < 0;
});
promises.push(this._rpc({
model: 'website.menu',
method: 'write',
args: [
[parseInt($el.data('oe-id'))],
{
'mega_menu_classes': classes.join(' '),
},
],
}));
}
// Saving cover properties on related model if any
var prom = this._saveCoverProperties($el);
if (prom) {
promises.push(prom);
}
return Promise.all(promises);
},
/**
* Restores mega menu behaviors and closes them (important to do before
* saving otherwise they would be saved opened).
*
* @private
* @returns {Promise}
*/
_restoreMegaMenus: function () {
var $megaMenuToggles = this.$('.o_mega_menu_toggle');
$megaMenuToggles.off('.wysiwyg_megamenu')
.attr('data-bs-toggle', 'dropdown')
.dropdown({});
return toggleDropdown($megaMenuToggles, false);
},
/**
* Toggles the mega menu.
*
* @private
* @returns {Promise}
*/
_toggleMegaMenu: function (toggleEl) {
const megaMenuEl = toggleEl.parentElement.querySelector('.o_mega_menu');
if (!megaMenuEl || !megaMenuEl.classList.contains('show')) {
return this.snippetsMenu.activateSnippet(false);
}
megaMenuEl.classList.add('o_no_parent_editor');
return this.snippetsMenu.activateSnippet($(megaMenuEl));
},
});
snippetsEditor.SnippetsMenu.include({
/**
* @override
*/
init: function () {
this._super(...arguments);
this._notActivableElementsSelector += ', .o_mega_menu_toggle';
},
/**
* @override
*/
start() {
const _super = this._super(...arguments);
if (this.options.enableTranslation) {
return _super;
}
if (this.$body[0].ownerDocument !== this.ownerDocument) {
this.$body.on('click.snippets_menu', '*', this._onClick);
}
return _super;
},
/**
* @override
*/
destroy() {
if (this.$body[0].ownerDocument !== this.ownerDocument) {
this.$body.off('.snippets_menu');
}
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
async cleanForSave() {
const getFromEditable = selector => this.options.editable[0].querySelectorAll(selector);
// Clean unstyled translations
return this._super(...arguments).then(() => {
for (const el of getFromEditable('.o_translation_without_style')) {
el.classList.remove('o_translation_without_style');
if (el.dataset.oeTranslationSaveSha) {
el.dataset.oeTranslationInitialSha = el.dataset.oeTranslationSaveSha;
delete el.dataset.oeTranslationSaveSha;
}
}
// Adapt translation values for `select` > `options`s and remove all
// temporary `.o_translation_select` elements.
for (const optionsEl of getFromEditable('.o_translation_select')) {
const selectEl = optionsEl.nextElementSibling;
const translatedOptions = optionsEl.children;
const selectOptions = selectEl.tagName === 'SELECT' ? [...selectEl.options] : [];
if (selectOptions.length === translatedOptions.length) {
selectOptions.map((option, i) => {
option.text = translatedOptions[i].textContent;
});
}
optionsEl.remove();
}
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
*/
_insertDropzone: function ($hook) {
var $hookParent = $hook.parent();
var $dropzone = this._super(...arguments);
$dropzone.attr('data-editor-message-default', $hookParent.attr('data-editor-message-default'));
$dropzone.attr('data-editor-message', $hookParent.attr('data-editor-message'));
$dropzone.attr('data-editor-sub-message', $hookParent.attr('data-editor-sub-message'));
return $dropzone;
},
});
export default WebsiteWysiwyg;
@@ -1,13 +1,14 @@
/** @odoo-module **/
import weWidgets from 'wysiwyg.widgets';
import {_t} from 'web.core';
import { _t } from "@web/core/l10n/translation";
import { browser } from "@web/core/browser/browser";
import { patch } from "@web/core/utils/patch";
weWidgets.LinkPopoverWidget.include({
events: Object.assign({}, weWidgets.LinkPopoverWidget.prototype.events, {
'click .o_we_full_url, .o_we_url_link': '_onPreviewLinkClick',
}),
import { LinkPopoverWidget } from '@web_editor/js/wysiwyg/widgets/link_popover_widget';
patch(LinkPopoverWidget.prototype, 'website_link_popower_widget', {
/**
* @override
*/
@@ -21,6 +22,7 @@ weWidgets.LinkPopoverWidget.include({
timeoutID = setTimeout(() => this.$target.popover('show'), 1500);
});
}
this.$el.on('click', '.o_we_full_url, .o_we_url_link', this._onPreviewLinkClick.bind(this));
return this._super(...arguments);
},
@@ -47,23 +49,20 @@ weWidgets.LinkPopoverWidget.include({
}
});
const NavbarLinkPopoverWidget = weWidgets.LinkPopoverWidget.extend({
events: Object.assign({}, weWidgets.LinkPopoverWidget.prototype.events, {
'click .js_edit_menu': '_onEditMenuClick',
}),
export class NavbarLinkPopoverWidget extends LinkPopoverWidget {
constructor(params) {
super(...arguments);
this.checkIsWebsiteDesigner = params.checkIsWebsiteDesigner;
this.onEditLinkClick = params.onEditLinkClick;
this.onEditMenuClick = params.onEditMenuClick;
}
/**
*
* @override
*/
async start() {
const _super = this._super.bind(this);
this.isWebsiteDesigner = await this._rpc({
'model': 'res.users',
'method': 'has_group',
'args': ['website.group_website_designer'],
});
const $removeLink = this.$('.o_we_remove_link');
this.isWebsiteDesigner = await this.checkIsWebsiteDesigner();
const $removeLink = this.$el.find('.o_we_remove_link');
// remove link has no sense on navbar menu links, instead show edit menu
if (this.isWebsiteDesigner) {
const $anchor = $('<a/>', {
@@ -71,13 +70,14 @@ const NavbarLinkPopoverWidget = weWidgets.LinkPopoverWidget.extend({
'data-bs-placement': 'top', 'data-bs-toggle': 'tooltip',
}).append($('<i/>', {class: 'fa fa-sitemap'}));
$removeLink.replaceWith($anchor);
$anchor.on('click', () => this.onEditMenuClick(this));
} else {
this.$('.o_we_edit_link').remove();
this.$el.find('.o_we_edit_link').remove();
$removeLink.remove();
}
return _super(...arguments);
},
return super.start(...arguments);
}
//--------------------------------------------------------------------------
// Handlers
@@ -90,53 +90,6 @@ const NavbarLinkPopoverWidget = weWidgets.LinkPopoverWidget.extend({
* @param {Event} ev
*/
_onEditLinkClick(ev) {
var self = this;
var $menu = this.$target.find('[data-oe-id]');
this.trigger_up('menu_dialog', {
name: $menu.text(),
url: $menu.parent().attr('href'),
save: (name, url) => {
let websiteId;
this.trigger_up('context_get', {
callback: ctx => websiteId = ctx['website_id'],
});
const data = {
id: $menu.data('oe-id'),
name,
url,
};
return this._rpc({
model: 'website.menu',
method: 'save',
args: [websiteId, {'data': [data]}],
}).then(function () {
self.options.wysiwyg.odooEditor.observerUnactive();
self.$target.attr('href', url);
$menu.text(name);
self.options.wysiwyg.odooEditor.observerActive();
});
},
});
this.popover.hide();
},
/**
* Opens the menu tree editor. On menu editor save, current page changes
* will also be saved.
*
* @private
* @param {Event} ev
*/
_onEditMenuClick(ev) {
const contentMenu = this.target.closest('[data-content_menu_id]');
const rootID = contentMenu ? parseInt(contentMenu.dataset.content_menu_id, 10) : undefined;
this.trigger_up('action_demand', {
actionName: 'edit_menu',
params: [rootID],
});
},
});
// Exact same static method but instantiating the specialized class.
NavbarLinkPopoverWidget.createFor = weWidgets.LinkPopoverWidget.createFor;
export default NavbarLinkPopoverWidget;
this.onEditLinkClick(this);
}
}
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { registry } from '@web/core/registry';
import { getWysiwygClass } from 'web_editor.loader';
import { loadLegacyWysiwygAssets } from '@web_editor/js/frontend/loader';
import { FullscreenIndication } from '../components/fullscreen_indication/fullscreen_indication';
import { WebsiteLoader } from '../components/website_loader/website_loader';
@@ -34,7 +34,6 @@ export const websiteService = {
let contentWindow;
let lastUrl;
let websiteRootInstance;
let Wysiwyg;
let isRestrictedEditor;
let isDesigner;
let hasMultiWebsites;
@@ -221,13 +220,7 @@ export const websiteService = {
websites = [...(await orm.searchRead('website', [], ['domain', 'id', 'name']))];
},
async loadWysiwyg() {
if (!Wysiwyg) {
Wysiwyg = await getWysiwygClass({
moduleName: 'website.wysiwyg',
additionnalAssets: ['website.assets_wysiwyg']
});
}
return Wysiwyg;
await loadLegacyWysiwygAssets(['website.assets_wysiwyg']);
},
blockPreview(showLoader, processId) {
if (!blockingProcesses.length) {
@@ -1,9 +1,9 @@
/** @odoo-module **/
import core from "web.core";
import {ColorpickerWidget} from "web.Colorpicker";
import options from "web_editor.snippets.options";
import weUtils from "web_editor.utils";
import { isCSSColor } from '@web/core/utils/colors';
var _t = core._t;
@@ -78,7 +78,7 @@ options.registry.InnerChart = options.Class.extend({
const color = el.dataset.backgroundColor || el.dataset.borderColor;
if (color) {
el.style.border = '2px solid';
el.style.borderColor = ColorpickerWidget.isCSSColor(color) ? color : weUtils.getCSSVariableValue(color, this.style);
el.style.borderColor = isCSSColor(color) ? color : weUtils.getCSSVariableValue(color, this.style);
}
});
},
@@ -1,9 +1,9 @@
/** @odoo-module alias=website.s_countdown **/
import {ColorpickerWidget} from "web.Colorpicker";
import core from "web.core";
import publicWidget from "web.public.widget";
import weUtils from "web_editor.utils";
import { isCSSColor } from '@web/core/utils/colors';
const qweb = core.qweb;
const _t = core._t;
@@ -83,7 +83,7 @@ const CountdownWidget = publicWidget.Widget.extend({
* @returns {string}
*/
_ensureCssColor: function (color) {
if (ColorpickerWidget.isCSSColor(color)) {
if (isCSSColor(color)) {
return color;
}
return weUtils.getCSSVariableValue(color) || this.defaultColor;
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog";
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog_wrapper";
import { ComponentWrapper } from "web.OwlCompatibility";
import core from "web.core";
import options from "web_editor.snippets.options";
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { ComponentWrapper } from "web.OwlCompatibility";
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog";
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog_wrapper";
import options from "web_editor.snippets.options";
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-inherit="web_editor.LinkDialog" t-inherit-mode="extension" owl="1">
<xpath expr="//*[@id='o_link_dialog_url_input']" position="after">
<small class="form-text text-muted">Hint: Type '/' to search an existing page and '#' to link to an anchor.</small>
</xpath>
</t>
<t t-inherit="web_editor.LinkTools" t-inherit-mode="extension" owl="1">
<xpath expr="//we-row[@id='url_row']" position="after">
<div style="text-align: center" t-attf-class="#{state.isButton ? ' d-none' : ''}">
<small>
Type '<span class="highlighted-text">/</span>' to search a page.
'<span class="highlighted-text">#</span>' to link to an anchor.
</small>
</div>
<we-row class="o_link_dialog_page_anchor d-none" t-attf-class="#{state.isButton ? ' d-none' : ''}">
<we-select class="o_we_user_value_widget o_we_sublevel_1">
<we-title>Page Anchor</we-title>
<div>
<div class="dropdown">
<button class="dropdown-toggle"
data-bs-toggle="dropdown" tabindex="-1" aria-expanded="false">
<we-toggler>
Loading...
</we-toggler>
</button>
<we-selection-items name="link_anchor" class="dropdown-menu link-style">
<we-button class="dropdown-item o_anchors_loading">Loading...</we-button>
</we-selection-items>
<span class="o_we_dropdown_caret"></span>
</div>
</div>
</we-select>
</we-row>
</xpath>
</t>
<t t-inherit="web_editor.toolbar" t-inherit-mode="extension" owl="1">
<xpath expr="//t[@t-if='this.isMobile()']//div[@id='list']" position="inside">
<div t-if="props.showAnimateText" title="Animate text" class="btn fa fa-play fa-fw o_we_animate_text"/>
</xpath>
<xpath expr="//t[@t-else]//div[@id='list']" position="inside">
<div t-if="props.showAnimateText" title="Animate text" class="btn fa fa-play fa-fw o_we_animate_text"/>
</xpath>
</t>
</templates>
@@ -1,41 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-extend="wysiwyg.widgets.link">
<t t-jquery="#o_link_dialog_url_input" t-operation="after">
<small class="form-text text-muted">Hint: Type '/' to search an existing page and '#' to link to an anchor.</small>
</t>
</t>
<t t-extend="wysiwyg.widgets.linkTools">
<t t-jquery="#url_row" t-operation="after">
<div style="text-align: center" t-attf-class="#{widget.isButton ? ' d-none' : ''}">
<small>
Type '<span class="highlighted-text">/</span>' to search a page.
'<span class="highlighted-text">#</span>' to link to an anchor.
</small>
</div>
<we-row class="o_link_dialog_page_anchor d-none" t-attf-class="#{widget.isButton ? ' d-none' : ''}">
<we-select class="o_we_user_value_widget o_we_sublevel_1">
<we-title>Page Anchor</we-title>
<div>
<div class="dropdown">
<button class="dropdown-toggle"
data-bs-toggle="dropdown" tabindex="-1" aria-expanded="false">
<we-toggler>
Loading...
</we-toggler>
</button>
<we-selection-items name="link_anchor" class="dropdown-menu link-style">
<we-button class="dropdown-item o_anchors_loading">Loading...</we-button>
</we-selection-items>
<span class="o_we_dropdown_caret"></span>
</div>
</div>
</we-select>
</we-row>
</t>
</t>
<!-- Anchor Name option dialog -->
<div t-name="website.dialog.anchorName">
<div class="mb-3 row">
@@ -159,12 +123,4 @@
<div class="mb-2" t-esc="contentText"/>
<div class="o_ace_editor_container"/>
</div>
<!-- Editor Toolbar -->
<t t-extend="web_editor.toolbar">
<t t-jquery="#list .oe-toggle-checklist" t-operation="replace"/>
<t t-jquery="#list" t-operation="append">
<div title="Animate text" class="btn fa fa-play fa-fw o_we_animate_text"/>
</t>
</t>
</templates>
@@ -42,7 +42,7 @@ wTourUtils.registerWebsitePreviewTour('edit_link_popover', {
},
{
content: "Click on 'Link' to open Link Dialog",
trigger: "#toolbar #create-link",
trigger: "#toolbar:not(.oe-floating) #create-link",
},
{
content: "Type the link URL /contactus",
@@ -144,7 +144,7 @@ wTourUtils.registerWebsitePreviewTour('edit_link_popover', {
},
{
content: "Toolbar should be shown (3)",
trigger: '#toolbar:has(#o_link_dialog_url_input:propValue(/contactus))',
trigger: '.oe-toolbar:not(.oe-floating):has(#o_link_dialog_url_input:propValue(/contactus))',
run: function () {}, // it's a check
},
{
@@ -158,7 +158,7 @@ wTourUtils.registerWebsitePreviewTour('edit_link_popover', {
},
{
content: "Toolbar should be shown (4)",
trigger: '#toolbar:has(#o_link_dialog_url_input:propValue(/))',
trigger: '.oe-toolbar:not(.oe-floating):has(#o_link_dialog_url_input:propValue(/))',
run: function () {}, // it's a check
},
// 4. Popover should close when clicking non-link element
@@ -24,11 +24,11 @@ wTourUtils.registerWebsitePreviewTour('link_tools', {
},
{
content: "Open link tools",
trigger: "#toolbar #create-link",
trigger: "#toolbar:not(.oe-floating) #create-link",
},
{
content: "Type the link URL odoo.com",
trigger: '#o_link_dialog_url_input',
trigger: '#toolbar:not(.oe-floating) #o_link_dialog_url_input',
run: 'text odoo.com'
},
clickOnImgStep,
@@ -37,9 +37,18 @@ wTourUtils.registerWebsitePreviewTour('link_tools', {
content: "Click on the newly created link",
trigger: 'iframe .s_text_image a[href="http://odoo.com"]:contains("odoo.com")',
},
{
content: "Label value should contain odoo.com",
trigger: '#o_link_dialog_label_input',
run: () => {
if ($('#o_link_dialog_label_input').val() !== 'odoo.com') {
throw new Error('Label value should contain odoo.com');
}
},
},
{
content: "Change content (editing the label input) to odoo website_2",
trigger: '#o_link_dialog_label_input[value="odoo.com"]',
trigger: '#o_link_dialog_label_input',
run: 'text odoo website_2',
},
{
@@ -57,9 +66,13 @@ wTourUtils.registerWebsitePreviewTour('link_tools', {
trigger: 'iframe .s_text_image a[href="http://odoo.com"]:contains("odoo website")',
},
{
content: "Check that the label input contains the new content",
trigger: '#o_link_dialog_label_input[value="odoo website"]',
run: () => null, // it's a check
content: "Label value should contain odoo website",
trigger: '#o_link_dialog_label_input',
run: () => {
if ($('#o_link_dialog_label_input').val() !== 'odoo website') {
throw new Error('Label value should contain odoo website');
}
},
},
{
content: "Link tools, should be open, change the url",
@@ -78,7 +91,7 @@ wTourUtils.registerWebsitePreviewTour('link_tools', {
},
{
content: "The new link content should be odoo website and url odoo.be",
trigger: '#toolbar .dropdown:has([name="link_style_color"]) > button',
trigger: '#toolbar:not(.oe-floating) .dropdown:has([name="link_style_color"]) > button',
},
{
// When doing automated testing, the link popover takes time to
@@ -92,7 +105,7 @@ wTourUtils.registerWebsitePreviewTour('link_tools', {
},
{
content: "Click on the secondary style button.",
trigger: '#toolbar we-button[data-value="secondary"]',
trigger: '#toolbar:not(.oe-floating) we-button[data-value="secondary"]',
},
...wTourUtils.clickOnSave(),
{
@@ -1,7 +1,7 @@
/** @odoo-module **/
import publicWidget from "web.public.widget";
import wysiwygLoader from "web_editor.loader";
import { loadWysiwygFromTextarea } from "@web_editor/js/frontend/loadWysiwygFromTextarea";
publicWidget.registry['public_user_editor_test'] = publicWidget.Widget.extend({
selector: 'textarea.o_public_user_editor_test_textarea',
@@ -11,6 +11,6 @@ publicWidget.registry['public_user_editor_test'] = publicWidget.Widget.extend({
*/
start: async function () {
await this._super(...arguments);
await wysiwygLoader.loadFromTextarea(this, this.el, {});
await loadWysiwygFromTextarea(this, this.el, {});
},
});
+1 -3
View File
@@ -2,6 +2,7 @@
import session from "web.session";
import wTourUtils from "website.tour_utils";
import { Wysiwyg } from "@web_editor/js/wysiwyg/wysiwyg";
var domReady = new Promise(function (resolve) {
$(resolve);
@@ -116,7 +117,6 @@ wTourUtils.registerWebsitePreviewTour('rte_translator', {
trigger: 'iframe #wrap p font:first',
run: function (actionHelper) {
actionHelper.text('translated Parseltongue text');
const Wysiwyg = odoo.__DEBUG__.services['web_editor.wysiwyg'];
Wysiwyg.setRange(this.$anchor.contents()[0], 22);
this.$anchor.trigger($.Event("keyup", {key: '_', keyCode: 95}));
this.$anchor.trigger('input');
@@ -127,7 +127,6 @@ wTourUtils.registerWebsitePreviewTour('rte_translator', {
run: function (actionHelper) {
actionHelper.click();
this.$anchor.prepend('&lt;{translated}&gt;');
const Wysiwyg = odoo.__DEBUG__.services['web_editor.wysiwyg'];
Wysiwyg.setRange(this.$anchor.contents()[0], 0);
this.$anchor.trigger($.Event("keyup", {key: '_', keyCode: 95}));
this.$anchor.trigger('input');
@@ -197,7 +196,6 @@ wTourUtils.registerWebsitePreviewTour('rte_translator', {
mousedown.initMouseEvent('mousedown', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el);
el.dispatchEvent(mousedown);
var mouseup = document.createEvent('MouseEvents');
const Wysiwyg = odoo.__DEBUG__.services['web_editor.wysiwyg'];
Wysiwyg.setRange(el.childNodes[2], 6, el.childNodes[2], 13);
mouseup.initMouseEvent('mouseup', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el);
el.dispatchEvent(mouseup);
@@ -338,7 +338,7 @@
trigger: 'iframe .o_edit_menu_popover .o_we_edit_link',
}, {
content: "Check that no URL field is suggested",
trigger: '#toolbar:has(#url_row:hidden)',
trigger: '.oe-toolbar:not(.oe-floating):has(#url_row:hidden)',
run: () => null,
}, {
content: "Change button's style",
@@ -33,7 +33,7 @@ wTourUtils.registerWebsitePreviewTour('website_text_edition', {
},
{
content: "Open the foreground colorpicker",
trigger: '#oe-text-color',
trigger: '#toolbar:not(.oe-floating) #oe-text-color',
},
{
content: "Go to the 'solid' tab",
+20 -20
View File
@@ -1,14 +1,10 @@
/** @odoo-module **/
import Wysiwyg from "website.wysiwyg";
import { WysiwygAdapterComponent } from '@website/components/wysiwyg_adapter/wysiwyg_adapter';
import "website.editor.snippets.options";
import { patch } from "@web/core/utils/patch";
Wysiwyg.include({
custom_events: Object.assign({}, Wysiwyg.prototype.custom_events, {
'set_blog_post_updated_tags': '_onSetBlogPostUpdatedTags',
}),
patch(WysiwygAdapterComponent.prototype, 'website_blog/static/src/js/wysiwyg.js', {
/**
* @override
*/
@@ -49,22 +45,14 @@ Wysiwyg.include({
async _saveBlogTags() {
for (const [key, tags] of Object.entries(this.blogTagsPerBlogPost)) {
const proms = tags.filter(tag => typeof tag.id === 'string').map(tag => {
return this._rpc({
model: 'blog.tag',
method: 'create',
args: [{
return this.orm.create("blog.tag", [{
'name': tag.name,
}],
});
}]);
});
const createdIDs = await Promise.all(proms);
const createdIDs = (await Promise.all(proms)).flat();
await this._rpc({
model: 'blog.post',
method: 'write',
args: [parseInt(key), {
'tag_ids': [[6, 0, tags.filter(tag => typeof tag.id === 'number').map(tag => tag.id).concat(createdIDs)]],
}],
await this.orm.write("blog.post", [parseInt(key)], {
'tag_ids': [[6, 0, tags.filter(tag => typeof tag.id === 'number').map(tag => tag.id).concat(createdIDs)]],
});
}
},
@@ -80,4 +68,16 @@ Wysiwyg.include({
_onSetBlogPostUpdatedTags: function (ev) {
this.blogTagsPerBlogPost[ev.data.blogPostID] = ev.data.tags;
},
/**
* @override
*/
_trigger_up(ev) {
if (ev.name === 'set_blog_post_updated_tags') {
this._onSetBlogPostUpdatedTags(ev);
return;
} else {
return this._super(...arguments);
}
},
});
-3
View File
@@ -76,9 +76,6 @@ Ask questions, get answers, no distractions
'website_forum/static/src/js/website_forum.share.js',
'website_forum/static/src/xml/public_templates.xml',
],
'web_editor.assets_wysiwyg': {
'website_forum/static/src/xml/forum_wysiwyg.xml',
},
},
'license': 'LGPL-3',
}
@@ -4,7 +4,7 @@ import dom from "web.dom";
import core from "web.core";
import {setCookie} from "web.utils.cookies";
import Dialog from "web.Dialog";
import wysiwygLoader from "web_editor.loader";
import { loadWysiwygFromTextarea } from "@web_editor/js/frontend/loadWysiwygFromTextarea";
import publicWidget from "web.public.widget";
import { Markup } from "web.utils";
import session from "web.session";
@@ -122,6 +122,16 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({
var options = {
toolbarTemplate: 'website_forum.web_editor_toolbar',
toolbarOptions: {
showColors: false,
showFontSize: false,
showHistory: true,
showHeading1: false,
showHeading2: false,
showHeading3: false,
showLink: hasFullEdit,
showImageEdit: hasFullEdit,
},
recordInfo: {
context: self._getContext(),
res_model: 'forum.post',
@@ -137,10 +147,7 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({
};
options.allowCommandLink = hasFullEdit;
options.allowCommandImage = hasFullEdit;
wysiwygLoader.loadFromTextarea(self, $textarea[0], options).then(wysiwyg => {
if (!hasFullEdit) {
wysiwyg.toolbar.$el.find('#link, #media').remove();
}
loadWysiwygFromTextarea(self, $textarea[0], options).then(wysiwyg => {
// float-start class messes up the post layout OPW 769721
$form.find('.note-editable').find('img.float-start').removeClass('float-start');
});
@@ -1,15 +0,0 @@
<templates id="template" xml:space="preserve">
<t t-name="website_forum.web_editor_toolbar" t-extend="web_editor.toolbar">
<t t-jquery="li#heading1-dropdown-item" t-operation="replace"/>
<t t-jquery="li#heading2-dropdown-item" t-operation="replace"/>
<t t-jquery="li#heading3-dropdown-item" t-operation="replace"/>
<t t-jquery="div#font-size" t-operation="replace"/>
<t t-jquery="div#colorInputButtonGroup" t-operation="replace"/>
<t t-jquery="div#toolbar" t-operation="append">
<div id="history" class="btn-group">
<div id="undo" data-call="undo" class="btn fa fa-undo"/>
<div id="redo" data-call="redo" class="btn fa fa-repeat"/>
</div>
</t>
</t>
</templates>
@@ -1,11 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="iframe_css_assets_edit" name="CSS assets for wysiwyg iframe content for popup" groups="base.group_user">
<t t-call-assets="web.assets_frontend" t-js="false"/>
<t t-call-assets="web_editor.assets_wysiwyg" t-js="false"/>
<t t-call-assets="website.assets_editor" t-js="false"/>
</template>
<template id="remove_external_snippets" inherit_id="website.external_snippets">
<xpath expr="//t[@id='newsletter_snippet']" position="replace"/>
<xpath expr="//t[@id='newsletter_popup_snippet']" position="replace"/>
@@ -1,7 +1,7 @@
/** @odoo-module alias=website_profile.website_profile **/
import publicWidget from "web.public.widget";
import wysiwygLoader from "web_editor.loader";
import { loadWysiwygFromTextarea } from "@web_editor/js/frontend/loadWysiwygFromTextarea";
publicWidget.registry.websiteProfile = publicWidget.Widget.extend({
selector: '.o_wprofile_email_validation_container',
@@ -59,7 +59,7 @@ publicWidget.registry.websiteProfileEditor = publicWidget.Widget.extend({
var $textarea = this.$('textarea.o_wysiwyg_loader');
this._wysiwyg = await wysiwygLoader.loadFromTextarea(this, $textarea[0], {
this._wysiwyg = await loadWysiwygFromTextarea(this, $textarea[0], {
recordInfo: {
context: this._getContext(),
res_model: 'res.users',
@@ -1,37 +1,32 @@
/** @odoo-module **/
import options from "web_editor.snippets.options";
import Wysiwyg from "website.wysiwyg";
import { WysiwygAdapterComponent } from '@website/components/wysiwyg_adapter/wysiwyg_adapter';
import { ComponentWrapper } from "web.OwlCompatibility";
import { MediaDialog, MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog";
import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog";
import { MediaDialogWrapper } from "@web_editor/components/media_dialog/media_dialog_wrapper";
import { useWowlService } from "@web/legacy/utils";
import {qweb, _t} from "web.core";
import {Markup} from "web.utils";
import Dialog from "web.Dialog";
import "website.editor.snippets.options";
import { patch } from "@web/core/utils/patch";
const { onRendered } = owl;
Wysiwyg.include({
custom_events: Object.assign(Wysiwyg.prototype.custom_events, {
get_ribbons: '_onGetRibbons',
get_ribbon_classes: '_onGetRibbonClasses',
delete_ribbon: '_onDeleteRibbon',
set_ribbon: '_onSetRibbon',
set_product_ribbon: '_onSetProductRibbon',
}),
patch(WysiwygAdapterComponent.prototype, 'website_sale/static/src/js/website_sale.editor.js', {
/**
* @override
*/
async willStart() {
const _super = this._super.bind(this);
async init() {
await this._super(...arguments);
let ribbons = [];
if (this._isProductListPage()) {
ribbons = await this._rpc({
model: 'product.ribbon',
method: 'search_read',
fields: ['id', 'html', 'bg_color', 'text_color', 'html_class'],
});
ribbons = await this.orm.searchRead(
'product.ribbon',
[],
['id', 'html', 'bg_color', 'text_color', 'html_class'],
);
}
this.ribbons = Object.fromEntries(ribbons.map(ribbon => {
ribbon.html = Markup(ribbon.html);
@@ -40,7 +35,6 @@ Wysiwyg.include({
this.originalRibbons = Object.assign({}, this.ribbons);
this.productTemplatesRibbons = [];
this.deletedRibbonClasses = '';
return _super(...arguments);
},
/**
* @override
@@ -81,31 +75,28 @@ Wysiwyg.include({
const proms = [];
let createdRibbonIds;
if (created.length > 0) {
proms.push(this._rpc({
method: 'create',
model: 'product.ribbon',
args: [created.map(ribbon => {
proms.push(this.orm.create(
'product.ribbon',
created.map(ribbon => {
ribbon = Object.assign({}, ribbon);
delete ribbon.id;
return ribbon;
})],
}).then(ids => createdRibbonIds = ids));
}),
).then(ids => createdRibbonIds = ids));
}
modified.forEach(ribbon => proms.push(this._rpc({
method: 'write',
model: 'product.ribbon',
args: [[ribbon.id], ribbon],
})));
modified.forEach(ribbon => proms.push(this.orm.write(
'product.ribbon',
[ribbon.id],
ribbon,
)));
if (deletedIds.length > 0) {
proms.push(this._rpc({
method: 'unlink',
model: 'product.ribbon',
args: [deletedIds],
}));
proms.push(this.orm.unlink(
'product.ribbon',
deletedIds,
));
}
await Promise.all(proms);
const localToServer = Object.assign(
this.ribbons,
@@ -131,11 +122,11 @@ Wysiwyg.include({
.map(([ribbonId, templateIds]) => {
const id = currentIds.includes(parseInt(ribbonId)) ? ribbonId : false;
return [id, templateIds];
}).map(([ribbonId, templateIds]) => this._rpc({
method: 'write',
model: 'product.template',
args: [templateIds, {'website_ribbon_id': localToServer[ribbonId].id}],
}));
}).map(([ribbonId, templateIds]) => this.orm.write(
'product.template',
templateIds,
{'website_ribbon_id': localToServer[ribbonId].id},
));
return Promise.all(setProductTemplateRibbons);
},
/**
@@ -201,6 +192,23 @@ Wysiwyg.include({
const {templateId, ribbonId} = ev.data;
this.productTemplatesRibbons.push({templateId, ribbonId});
},
/**
* @override
*/
_trigger_up(ev) {
const methods = {
get_ribbons: this._onGetRibbons.bind(this),
get_ribbon_classes: this._onGetRibbonClasses.bind(this),
delete_ribbon: this._onDeleteRibbon.bind(this),
set_ribbon: this._onSetRibbon.bind(this),
set_product_ribbon: this._onSetProductRibbon.bind(this),
}
if (methods[ev.name]) {
return methods[ev.name](ev);
} else {
return this._super(...arguments);
}
}
});
options.registry.WebsiteSaleGridLayout = options.Class.extend({
-2
View File
@@ -241,8 +241,6 @@ Featuring
'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/core/**/*.js',
'web/static/src/env.js',
'web/static/src/legacy/js/services/session.js',