diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website/static/src/js/editor | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website/static/src/js/editor')
| -rw-r--r-- | addons/website/static/src/js/editor/editor.js | 18 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/editor_menu.js | 256 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/editor_menu_translate.js | 109 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/mega_menu.js | 0 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/rte.summernote.js | 59 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/snippets.editor.js | 245 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/snippets.options.js | 2612 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/widget_link.js | 104 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/wysiwyg_multizone.js | 286 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/wysiwyg_multizone_translate.js | 301 |
10 files changed, 3990 insertions, 0 deletions
diff --git a/addons/website/static/src/js/editor/editor.js b/addons/website/static/src/js/editor/editor.js new file mode 100644 index 00000000..ca26c092 --- /dev/null +++ b/addons/website/static/src/js/editor/editor.js @@ -0,0 +1,18 @@ +odoo.define('website.editor', function (require) { +'use strict'; + +var weWidgets = require('web_editor.widget'); +var wUtils = require('website.utils'); + +weWidgets.LinkDialog.include({ + /** + * Allows the URL input to propose existing website pages. + * + * @override + */ + start: function () { + wUtils.autocompleteWithPages(this, this.$('input[name="url"]')); + return this._super.apply(this, arguments); + }, +}); +}); diff --git a/addons/website/static/src/js/editor/editor_menu.js b/addons/website/static/src/js/editor/editor_menu.js new file mode 100644 index 00000000..e330df97 --- /dev/null +++ b/addons/website/static/src/js/editor/editor_menu.js @@ -0,0 +1,256 @@ +odoo.define('website.editor.menu', function (require) { +'use strict'; + +var Dialog = require('web.Dialog'); +var dom = require('web.dom'); +var Widget = require('web.Widget'); +var core = require('web.core'); +var Wysiwyg = require('web_editor.wysiwyg.root'); + +var _t = core._t; + +var WysiwygMultizone = Wysiwyg.extend({ + assetLibs: Wysiwyg.prototype.assetLibs.concat(['website.compiled_assets_wysiwyg']), + _getWysiwygContructor: function () { + return odoo.__DEBUG__.services['web_editor.wysiwyg.multizone']; + } +}); + +var EditorMenu = Widget.extend({ + template: 'website.editorbar', + xmlDependencies: ['/website/static/src/xml/website.editor.xml'], + events: { + 'click button[data-action=undo]': '_onUndoClick', + 'click button[data-action=redo]': '_onRedoClick', + 'click button[data-action=save]': '_onSaveClick', + 'click button[data-action=cancel]': '_onCancelClick', + }, + custom_events: { + request_save: '_onSnippetRequestSave', + get_clean_html: '_onGetCleanHTML', + }, + + /** + * @override + */ + willStart: function () { + var self = this; + this.$el = null; // temporary null to avoid hidden error (@see start) + return this._super() + .then(function () { + var $wrapwrap = $('#wrapwrap'); + $wrapwrap.removeClass('o_editable'); // clean the dom before edition + self.editable($wrapwrap).addClass('o_editable'); + self.wysiwyg = self._wysiwygInstance(); + }); + }, + /** + * @override + */ + start: function () { + var self = this; + this.$el.css({width: '100%'}); + return this.wysiwyg.attachTo($('#wrapwrap')).then(function () { + self.trigger_up('edit_mode'); + self.$el.css({width: ''}); + }); + }, + /** + * @override + */ + destroy: function () { + this.trigger_up('readonly_mode'); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Asks the user if they really wants to discard their changes (if any), + * then simply reloads the page if they want to. + * + * @param {boolean} [reload=true] + * true if the page has to be reloaded when the user answers yes + * (do nothing otherwise but add this to allow class extension) + * @returns {Deferred} + */ + cancel: function (reload) { + var self = this; + var def = new Promise(function (resolve, reject) { + if (!self.wysiwyg.isDirty()) { + resolve(); + } else { + var confirm = Dialog.confirm(self, _t("If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode."), { + confirm_callback: resolve, + }); + confirm.on('closed', self, reject); + } + }); + + return def.then(function () { + self.trigger_up('edition_will_stopped'); + var $wrapwrap = $('#wrapwrap'); + self.editable($wrapwrap).removeClass('o_editable'); + if (reload !== false) { + window.onbeforeunload = null; + self.wysiwyg.destroy(); + return self._reload(); + } else { + self.wysiwyg.destroy(); + self.trigger_up('readonly_mode'); + self.trigger_up('edition_was_stopped'); + self.destroy(); + } + }); + }, + /** + * Asks the snippets to clean themself, then saves the page, then reloads it + * if asked to. + * + * @param {boolean} [reload=true] + * true if the page has to be reloaded after the save + * @returns {Promise} + */ + save: async function (reload) { + if (this._saving) { + return false; + } + var self = this; + this._saving = true; + this.trigger_up('edition_will_stopped'); + return this.wysiwyg.save(false).then(function (result) { + var $wrapwrap = $('#wrapwrap'); + self.editable($wrapwrap).removeClass('o_editable'); + if (result.isDirty && reload !== false) { + // remove top padding because the connected bar is not visible + $('body').removeClass('o_connected_user'); + return self._reload(); + } else { + self.wysiwyg.destroy(); + self.trigger_up('edition_was_stopped'); + self.destroy(); + } + return true; + }).guardedCatch(() => { + this._saving = false; + }); + }, + /** + * Returns the editable areas on the page. + * + * @param {DOM} $wrapwrap + * @returns {jQuery} + */ + editable: function ($wrapwrap) { + return $wrapwrap.find('[data-oe-model]') + .not('.o_not_editable') + .filter(function () { + var $parent = $(this).closest('.o_editable, .o_not_editable'); + return !$parent.length || $parent.hasClass('o_editable'); + }) + .not('link, script') + .not('[data-oe-readonly]') + .not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]') + .not('.oe_snippet_editor') + .not('hr, br, input, textarea') + .add('.o_editable'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _wysiwygInstance: function () { + var context; + this.trigger_up('context_get', { + callback: function (ctx) { + context = ctx; + }, + }); + return new WysiwygMultizone(this, { + snippets: 'website.snippets', + recordInfo: { + context: context, + data_res_model: 'website', + data_res_id: context.website_id, + } + }); + }, + /** + * Reloads the page in non-editable mode, with the right scrolling. + * + * @private + * @returns {Deferred} (never resolved, the page is reloading anyway) + */ + _reload: function () { + $('body').addClass('o_wait_reload'); + this.wysiwyg.destroy(); + this.$el.hide(); + window.location.hash = 'scrollTop=' + window.document.body.scrollTop; + window.location.reload(true); + return new Promise(function () {}); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "Discard" button is clicked -> discards the changes. + * + * @private + */ + _onCancelClick: function () { + this.cancel(true); + }, + /** + * Get the cleaned value of the editable element. + * + * @private + * @param {OdooEvent} ev + */ + _onGetCleanHTML: function (ev) { + ev.data.callback(this.wysiwyg.getValue({$layout: ev.data.$layout})); + }, + /** + * Snippet (menu_data) can request to save the document to leave the page + * + * @private + * @param {OdooEvent} ev + * @param {object} ev.data + * @param {function} ev.data.onSuccess + * @param {function} ev.data.onFailure + */ + _onSnippetRequestSave: function (ev) { + this.save(false).then(ev.data.onSuccess, ev.data.onFailure); + }, + /** + * Called when the "Save" button is clicked -> saves the changes. + * + * @private + */ + _onSaveClick: function (ev) { + const restore = dom.addButtonLoadingEffect(ev.currentTarget); + this.save().then(restore).guardedCatch(restore); + }, + /** + * @private + */ + _onUndoClick() { + $('.note-history [data-event=undo]').first().click(); + }, + /** + * @private + */ + _onRedoClick() { + $('.note-history [data-event=redo]').first().click(); + }, +}); + +return EditorMenu; +}); diff --git a/addons/website/static/src/js/editor/editor_menu_translate.js b/addons/website/static/src/js/editor/editor_menu_translate.js new file mode 100644 index 00000000..3016ade6 --- /dev/null +++ b/addons/website/static/src/js/editor/editor_menu_translate.js @@ -0,0 +1,109 @@ +odoo.define('website.editor.menu.translate', function (require) { +'use strict'; + +require('web.dom_ready'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var localStorage = require('web.local_storage'); +var Wysiwyg = require('web_editor.wysiwyg.root'); +var EditorMenu = require('website.editor.menu'); + +var _t = core._t; + +var localStorageNoDialogKey = 'website_translator_nodialog'; + +var TranslatorInfoDialog = Dialog.extend({ + template: 'website.TranslatorInfoDialog', + xmlDependencies: Dialog.prototype.xmlDependencies.concat( + ['/website/static/src/xml/translator.xml'] + ), + + /** + * @constructor + */ + init: function (parent, options) { + this._super(parent, _.extend({ + title: _t("Translation Info"), + buttons: [ + {text: _t("Ok, never show me this again"), classes: 'btn-primary', close: true, click: this._onStrongOk.bind(this)}, + {text: _t("Ok"), close: true} + ], + }, options || {})); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the "strong" ok is clicked -> adapt localstorage to make sure + * the dialog is never displayed again. + * + * @private + */ + _onStrongOk: function () { + localStorage.setItem(localStorageNoDialogKey, true); + }, +}); + +var WysiwygTranslate = Wysiwyg.extend({ + assetLibs: Wysiwyg.prototype.assetLibs.concat(['website.compiled_assets_wysiwyg']), + _getWysiwygContructor: function () { + return odoo.__DEBUG__.services['web_editor.wysiwyg.multizone.translate']; + } +}); + +var TranslatorMenu = EditorMenu.extend({ + + /** + * @override + */ + start: function () { + if (!localStorage.getItem(localStorageNoDialogKey)) { + new TranslatorInfoDialog(this).open(); + } + + return this._super(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns the editable areas on the page. + * + * @param {DOM} $wrapwrap + * @returns {jQuery} + */ + editable: function ($wrapwrap) { + var selector = '[data-oe-translation-id], '+ + '[data-oe-model][data-oe-id][data-oe-field], ' + + '[placeholder*="data-oe-translation-id="], ' + + '[title*="data-oe-translation-id="], ' + + '[alt*="data-oe-translation-id="]'; + var $edit = $wrapwrap.find(selector); + $edit.filter(':has(' + selector + ')').attr('data-oe-readonly', true); + return $edit.not('[data-oe-readonly]'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _wysiwygInstance: function () { + var context; + this.trigger_up('context_get', { + callback: function (ctx) { + context = ctx; + }, + }); + return new WysiwygTranslate(this, {lang: context.lang}); + }, +}); + +return TranslatorMenu; +}); diff --git a/addons/website/static/src/js/editor/mega_menu.js b/addons/website/static/src/js/editor/mega_menu.js new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/addons/website/static/src/js/editor/mega_menu.js diff --git a/addons/website/static/src/js/editor/rte.summernote.js b/addons/website/static/src/js/editor/rte.summernote.js new file mode 100644 index 00000000..36cadca7 --- /dev/null +++ b/addons/website/static/src/js/editor/rte.summernote.js @@ -0,0 +1,59 @@ +odoo.define('website.rte.summernote', function (require) { +'use strict'; + +var core = require('web.core'); +require('web_editor.rte.summernote'); + +var eventHandler = $.summernote.eventHandler; +var renderer = $.summernote.renderer; +var tplIconButton = renderer.getTemplate().iconButton; +var _t = core._t; + +var fn_tplPopovers = renderer.tplPopovers; +renderer.tplPopovers = function (lang, options) { + var $popover = $(fn_tplPopovers.call(this, lang, options)); + $popover.find('.note-image-popover .btn-group:has([data-value="img-thumbnail"])').append( + tplIconButton('fa fa-object-ungroup', { + title: _t('Transform the picture (click twice to reset transformation)'), + event: 'transform', + })); + return $popover; +}; + +$.summernote.pluginEvents.transform = function (event, editor, layoutInfo, sorted) { + var $selection = layoutInfo.handle().find('.note-control-selection'); + var $image = $($selection.data('target')); + + if ($image.data('transfo-destroy')) { + $image.removeData('transfo-destroy'); + return; + } + + $image.transfo(); + + var mouseup = function (event) { + $('.note-popover button[data-event="transform"]').toggleClass('active', $image.is('[style*="transform"]')); + }; + $(document).on('mouseup', mouseup); + + var mousedown = function (event) { + if (!$(event.target).closest('.transfo-container').length) { + $image.transfo('destroy'); + $(document).off('mousedown', mousedown).off('mouseup', mouseup); + } + if ($(event.target).closest('.note-popover').length) { + $image.data('transfo-destroy', true).attr('style', ($image.attr('style') || '').replace(/[^;]*transform[\w:]*;?/g, '')); + } + $image.trigger('content_changed'); + }; + $(document).on('mousedown', mousedown); +}; + +var fn_boutton_update = eventHandler.modules.popover.button.update; +eventHandler.modules.popover.button.update = function ($container, oStyle) { + fn_boutton_update.call(this, $container, oStyle); + $container.find('button[data-event="transform"]') + .toggleClass('active', $(oStyle.image).is('[style*="transform"]')) + .toggleClass('d-none', !$(oStyle.image).is('img')); +}; +}); diff --git a/addons/website/static/src/js/editor/snippets.editor.js b/addons/website/static/src/js/editor/snippets.editor.js new file mode 100644 index 00000000..15f80046 --- /dev/null +++ b/addons/website/static/src/js/editor/snippets.editor.js @@ -0,0 +1,245 @@ +odoo.define('website.snippet.editor', function (require) { +'use strict'; + +const {qweb, _t, _lt} = require('web.core'); +const Dialog = require('web.Dialog'); +const weSnippetEditor = require('web_editor.snippet.editor'); +const wSnippetOptions = require('website.editor.snippets.options'); + +const FontFamilyPickerUserValueWidget = wSnippetOptions.FontFamilyPickerUserValueWidget; + +weSnippetEditor.Class.include({ + xmlDependencies: (weSnippetEditor.Class.prototype.xmlDependencies || []) + .concat(['/website/static/src/xml/website.editor.xml']), + events: _.extend({}, weSnippetEditor.Class.prototype.events, { + 'click .o_we_customize_theme_btn': '_onThemeTabClick', + }), + custom_events: Object.assign({}, weSnippetEditor.Class.prototype.custom_events, { + 'gmap_api_request': '_onGMapAPIRequest', + 'gmap_api_key_request': '_onGMapAPIKeyRequest', + }), + tabs: _.extend({}, weSnippetEditor.Class.prototype.tabs, { + THEME: 'theme', + }), + optionsTabStructure: [ + ['theme-colors', _lt("Theme Colors")], + ['theme-options', _lt("Theme Options")], + ['website-settings', _lt("Website Settings")], + ], + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeSnippetTemplates: function (html) { + const $html = $(html); + const fontVariables = _.map($html.find('we-fontfamilypicker[data-variable]'), el => { + return el.dataset.variable; + }); + FontFamilyPickerUserValueWidget.prototype.fontVariables = fontVariables; + + return this._super(...arguments); + }, + /** + * Depending of the demand, reconfigure they gmap key or configure it + * if not already defined. + * + * @private + * @param {boolean} [reconfigure=false] + * @param {boolean} [onlyIfUndefined=false] + */ + async _configureGMapAPI({reconfigure, onlyIfUndefined}) { + const apiKey = await new Promise(resolve => { + this.getParent().trigger_up('gmap_api_key_request', { + onSuccess: key => resolve(key), + }); + }); + if (!reconfigure && (apiKey || !onlyIfUndefined)) { + return false; + } + let websiteId; + this.trigger_up('context_get', { + callback: ctx => websiteId = ctx['website_id'], + }); + return new Promise(resolve => { + let invalidated = false; + const dialog = new Dialog(this, { + size: 'medium', + title: _t("Google Map API Key"), + buttons: [ + {text: _t("Save"), classes: 'btn-primary', click: async (ev) => { + const $apiKeyInput = dialog.$('#api_key_input'); + const valueAPIKey = $apiKeyInput.val(); + const $apiKeyHelp = dialog.$('#api_key_help'); + if (!valueAPIKey) { + $apiKeyInput.addClass('is-invalid'); + $apiKeyHelp.text(_t("Enter an API Key")); + return; + } + const $button = $(ev.currentTarget); + $button.prop('disabled', true); + try { + const response = await fetch(`https://maps.googleapis.com/maps/api/staticmap?center=belgium&size=10x10&key=${valueAPIKey}`); + if (response.status === 200) { + await this._rpc({ + model: 'website', + method: 'write', + args: [ + [websiteId], + {google_maps_api_key: valueAPIKey}, + ], + }); + invalidated = true; + dialog.close(); + } else { + const text = await response.text(); + $apiKeyInput.addClass('is-invalid'); + $apiKeyHelp.empty().text( + _t("Invalid API Key. The following error was returned by Google:") + ).append($('<i/>', { + text: text, + class: 'ml-1', + })); + } + } catch (e) { + $apiKeyHelp.text(_t("Check your connection and try again")); + } finally { + $button.prop("disabled", false); + } + }}, + {text: _t("Cancel"), close: true} + ], + $content: $(qweb.render('website.s_google_map_modal', { + apiKey: apiKey, + })), + }); + dialog.on('closed', this, () => resolve(invalidated)); + dialog.open(); + }); + }, + /** + * @override + */ + _getScrollOptions(options = {}) { + const finalOptions = this._super(...arguments); + if (!options.offsetElements || !options.offsetElements.$top) { + const $header = $('#top'); + if ($header.length) { + finalOptions.offsetElements = finalOptions.offsetElements || {}; + finalOptions.offsetElements.$top = $header; + } + } + return finalOptions; + }, + /** + * @private + * @param {OdooEvent} ev + * @param {string} gmapRequestEventName + */ + async _handleGMapRequest(ev, gmapRequestEventName) { + ev.stopPropagation(); + const reconfigured = await this._configureGMapAPI({ + reconfigure: ev.data.reconfigure, + onlyIfUndefined: ev.data.configureIfNecessary, + }); + this.getParent().trigger_up(gmapRequestEventName, { + refetch: reconfigured, + editableMode: true, + onSuccess: key => ev.data.onSuccess(key), + }); + }, + /** + * @override + */ + _updateLeftPanelContent: function ({content, tab}) { + this._super(...arguments); + this.$('.o_we_customize_theme_btn').toggleClass('active', tab === this.tabs.THEME); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onGMapAPIRequest(ev) { + this._handleGMapRequest(ev, 'gmap_api_request'); + }, + /** + * @private + * @param {OdooEvent} ev + */ + _onGMapAPIKeyRequest(ev) { + this._handleGMapRequest(ev, 'gmap_api_key_request'); + }, + /** + * @private + */ + async _onThemeTabClick(ev) { + // Note: nothing async here but start the loading effect asap + let releaseLoader; + try { + const promise = new Promise(resolve => releaseLoader = resolve); + this._execWithLoadingEffect(() => promise, false, 0); + // loader is added to the DOM synchronously + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + // ensure loader is rendered: first call asks for the (already done) DOM update, + // second call happens only after rendering the first "updates" + + if (!this.topFakeOptionEl) { + let el; + for (const [elementName, title] of this.optionsTabStructure) { + const newEl = document.createElement(elementName); + newEl.dataset.name = title; + if (el) { + el.appendChild(newEl); + } else { + this.topFakeOptionEl = newEl; + } + el = newEl; + } + this.bottomFakeOptionEl = el; + this.el.appendChild(this.topFakeOptionEl); + } + + // Need all of this in that order so that: + // - the element is visible and can be enabled and the onFocus method is + // called each time. + // - the element is hidden afterwards so it does not take space in the + // DOM, same as the overlay which may make a scrollbar appear. + this.topFakeOptionEl.classList.remove('d-none'); + const editorPromise = this._activateSnippet($(this.bottomFakeOptionEl)); + releaseLoader(); // because _activateSnippet uses the same mutex as the loader + releaseLoader = undefined; + const editor = await editorPromise; + this.topFakeOptionEl.classList.add('d-none'); + editor.toggleOverlay(false); + + this._updateLeftPanelContent({ + tab: this.tabs.THEME, + }); + } catch (e) { + // Normally the loading effect is removed in case of error during the action but here + // the actual activity is happening outside of the action, the effect must therefore + // be cleared in case of error as well + if (releaseLoader) { + releaseLoader(); + } + throw e; + } + }, +}); + +weSnippetEditor.Editor.include({ + layoutElementsSelector: [ + weSnippetEditor.Editor.prototype.layoutElementsSelector, + '.s_parallax_bg', + '.o_bg_video_container', + ].join(','), +}); +}); diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js new file mode 100644 index 00000000..d0b032a1 --- /dev/null +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -0,0 +1,2612 @@ +odoo.define('website.editor.snippets.options', function (require) { +'use strict'; + +const {ColorpickerWidget} = require('web.Colorpicker'); +const config = require('web.config'); +var core = require('web.core'); +var Dialog = require('web.Dialog'); +const dom = require('web.dom'); +const weUtils = require('web_editor.utils'); +var options = require('web_editor.snippets.options'); +const wUtils = require('website.utils'); +require('website.s_popup_options'); + +var _t = core._t; +var qweb = core.qweb; + +const InputUserValueWidget = options.userValueWidgetsRegistry['we-input']; +const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select']; + +const UrlPickerUserValueWidget = InputUserValueWidget.extend({ + custom_events: _.extend({}, InputUserValueWidget.prototype.custom_events || {}, { + 'website_url_chosen': '_onWebsiteURLChosen', + }), + events: _.extend({}, InputUserValueWidget.prototype.events || {}, { + 'click .o_we_redirect_to': '_onRedirectTo', + }), + + /** + * @override + */ + start: async function () { + await this._super(...arguments); + const linkButton = document.createElement('we-button'); + const icon = document.createElement('i'); + icon.classList.add('fa', 'fa-fw', 'fa-external-link') + linkButton.classList.add('o_we_redirect_to'); + linkButton.title = _t("Redirect to URL in a new tab"); + linkButton.appendChild(icon); + this.containerEl.appendChild(linkButton); + this.el.classList.add('o_we_large_input'); + this.inputEl.classList.add('text-left'); + const options = { + position: { + collision: 'flip fit', + }, + classes: { + "ui-autocomplete": 'o_website_ui_autocomplete' + }, + } + wUtils.autocompleteWithPages(this, $(this.inputEl), options); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the autocomplete change the input value. + * + * @private + * @param {OdooEvent} ev + */ + _onWebsiteURLChosen: function (ev) { + this._value = this.inputEl.value; + this._onUserValueChange(ev); + }, + /** + * Redirects to the URL the widget currently holds. + * + * @private + */ + _onRedirectTo: function () { + if (this._value) { + window.open(this._value, '_blank'); + } + }, +}); + +const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ + xmlDependencies: (SelectUserValueWidget.prototype.xmlDependencies || []) + .concat(['/website/static/src/xml/website.editor.xml']), + events: _.extend({}, SelectUserValueWidget.prototype.events || {}, { + 'click .o_we_add_google_font_btn': '_onAddGoogleFontClick', + 'click .o_we_delete_google_font_btn': '_onDeleteGoogleFontClick', + }), + fontVariables: [], // Filled by editor menu when all options are loaded + + /** + * @override + */ + start: async function () { + const style = window.getComputedStyle(document.documentElement); + const nbFonts = parseInt(weUtils.getCSSVariableValue('number-of-fonts', style)); + const googleFontsProperty = weUtils.getCSSVariableValue('google-fonts', style); + this.googleFonts = googleFontsProperty ? googleFontsProperty.split(/\s*,\s*/g) : []; + this.googleFonts = this.googleFonts.map(font => font.substring(1, font.length - 1)); // Unquote + + await this._super(...arguments); + + const fontEls = []; + const methodName = this.el.dataset.methodName || 'customizeWebsiteVariable'; + const variable = this.el.dataset.variable; + _.times(nbFonts, fontNb => { + const realFontNb = fontNb + 1; + const fontEl = document.createElement('we-button'); + fontEl.classList.add(`o_we_option_font_${realFontNb}`); + fontEl.dataset.variable = variable; + fontEl.dataset[methodName] = weUtils.getCSSVariableValue(`font-number-${realFontNb}`, style); + fontEl.dataset.font = realFontNb; + fontEls.push(fontEl); + this.menuEl.appendChild(fontEl); + }); + + if (this.googleFonts.length) { + const googleFontsEls = fontEls.slice(-this.googleFonts.length); + googleFontsEls.forEach((el, index) => { + $(el).append(core.qweb.render('website.delete_google_font_btn', { + index: index, + })); + }); + } + $(this.menuEl).append($(core.qweb.render('website.add_google_font_btn', { + variable: variable, + }))); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async setValue() { + await this._super(...arguments); + + for (const className of this.menuTogglerEl.classList) { + if (className.match(/^o_we_option_font_\d+$/)) { + this.menuTogglerEl.classList.remove(className); + } + } + const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); + if (activeWidget) { + this.menuTogglerEl.classList.add(`o_we_option_font_${activeWidget.el.dataset.font}`); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAddGoogleFontClick: function (ev) { + const variable = $(ev.currentTarget).data('variable'); + const dialog = new Dialog(this, { + title: _t("Add a Google Font"), + $content: $(core.qweb.render('website.dialog.addGoogleFont')), + buttons: [ + { + text: _t("Save & Reload"), + classes: 'btn-primary', + click: async () => { + const inputEl = dialog.el.querySelector('.o_input_google_font'); + // if font page link (what is expected) + let m = inputEl.value.match(/\bspecimen\/([\w+]+)/); + if (!m) { + // if embed code (so that it works anyway if the user put the embed code instead of the page link) + m = inputEl.value.match(/\bfamily=([\w+]+)/); + if (!m) { + inputEl.classList.add('is-invalid'); + return; + } + } + + let isValidFamily = false; + + try { + const result = await fetch("https://fonts.googleapis.com/css?family=" + m[1], {method: 'HEAD'}); + // Google fonts server returns a 400 status code if family is not valid. + if (result.ok) { + isValidFamily = true; + } + } catch (error) { + console.error(error); + } + + if (!isValidFamily) { + inputEl.classList.add('is-invalid'); + return; + } + + const font = m[1].replace(/\+/g, ' '); + this.googleFonts.push(font); + this.trigger_up('google_fonts_custo_request', { + values: {[variable]: `'${font}'`}, + googleFonts: this.googleFonts, + }); + }, + }, + { + text: _t("Discard"), + close: true, + }, + ], + }); + dialog.open(); + }, + /** + * @private + * @param {Event} ev + */ + _onDeleteGoogleFontClick: async function (ev) { + ev.preventDefault(); + + const save = await new Promise(resolve => { + Dialog.confirm(this, _t("Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?"), { + confirm_callback: () => resolve(true), + cancel_callback: () => resolve(false), + }); + }); + if (!save) { + return; + } + + // Remove Google font + const googleFontIndex = parseInt(ev.target.dataset.fontIndex); + const googleFont = this.googleFonts[googleFontIndex]; + this.googleFonts.splice(googleFontIndex, 1); + + // Adapt font variable indexes to the removal + const values = {}; + const style = window.getComputedStyle(document.documentElement); + _.each(FontFamilyPickerUserValueWidget.prototype.fontVariables, variable => { + const value = weUtils.getCSSVariableValue(variable, style); + if (value.substring(1, value.length - 1) === googleFont) { + // If an element is using the google font being removed, reset + // it to the theme default. + values[variable] = 'null'; + } + }); + + this.trigger_up('google_fonts_custo_request', { + values: values, + googleFonts: this.googleFonts, + }); + }, +}); + +const GPSPicker = InputUserValueWidget.extend({ + events: { // Explicitely not consider all InputUserValueWidget events + 'blur input': '_onInputBlur', + }, + + /** + * @constructor + */ + init() { + this._super(...arguments); + this._gmapCacheGPSToPlace = {}; + }, + /** + * @override + */ + async willStart() { + await this._super(...arguments); + this._gmapLoaded = await new Promise(resolve => { + this.trigger_up('gmap_api_request', { + editableMode: true, + configureIfNecessary: true, + onSuccess: key => resolve(!!key), + }); + }); + if (!this._gmapLoaded) { + this.trigger_up('user_value_widget_critical'); + return; + } + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + this.el.classList.add('o_we_large_input'); + if (!this._gmapLoaded) { + return; + } + + this._gmapAutocomplete = new google.maps.places.Autocomplete(this.inputEl, {types: ['geocode']}); + google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this)); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getMethodsParams: function (methodName) { + return Object.assign({gmapPlace: this._gmapPlace || {}}, this._super(...arguments)); + }, + /** + * @override + */ + async setValue() { + await this._super(...arguments); + + await new Promise(resolve => { + const gps = this._value; + if (this._gmapCacheGPSToPlace[gps]) { + this._gmapPlace = this._gmapCacheGPSToPlace[gps]; + resolve(); + return; + } + const service = new google.maps.places.PlacesService(document.createElement('div')); + const p = gps.substring(1).slice(0, -1).split(','); + const location = new google.maps.LatLng(p[0] || 0, p[1] || 0); + service.nearbySearch({ + // Do a 'nearbySearch' followed by 'getDetails' to avoid using + // GMap Geocoder which the user may not have enabled... but + // ideally Geocoder should be used to get the exact location at + // those coordinates and to limit billing query count. + location: location, + radius: 1, + }, (results, status) => { + const GMAP_CRITICAL_ERRORS = [google.maps.places.PlacesServiceStatus.REQUEST_DENIED, google.maps.places.PlacesServiceStatus.UNKNOWN_ERROR]; + if (status === google.maps.places.PlacesServiceStatus.OK) { + service.getDetails({ + placeId: results[0].place_id, + fields: ['geometry', 'formatted_address'], + }, (place, status) => { + resolve(); + if (status === google.maps.places.PlacesServiceStatus.OK) { + this._gmapCacheGPSToPlace[gps] = place; + this._gmapPlace = place; + } else if (GMAP_CRITICAL_ERRORS.includes(status)) { + this.trigger_up('user_value_widget_critical'); + } + }); + } else if (GMAP_CRITICAL_ERRORS.includes(status)) { + resolve(); + this.trigger_up('user_value_widget_critical'); + } + }); + }); + if (this._gmapPlace) { + this.inputEl.value = this._gmapPlace.formatted_address; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onPlaceChanged(ev) { + const gmapPlace = this._gmapAutocomplete.getPlace(); + if (gmapPlace && gmapPlace.geometry) { + this._gmapPlace = gmapPlace; + const location = this._gmapPlace.geometry.location; + this._value = `(${location.lat()},${location.lng()})`; + this._gmapCacheGPSToPlace[this._value] = gmapPlace; + this._onUserValueChange(ev); + } + }, +}); + +options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget; +options.userValueWidgetsRegistry['we-fontfamilypicker'] = FontFamilyPickerUserValueWidget; +options.userValueWidgetsRegistry['we-gpspicker'] = GPSPicker; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +options.Class.include({ + xmlDependencies: (options.Class.prototype.xmlDependencies || []) + .concat(['/website/static/src/xml/website.editor.xml']), + custom_events: _.extend({}, options.Class.prototype.custom_events || {}, { + 'google_fonts_custo_request': '_onGoogleFontsCustoRequest', + }), + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + customizeWebsiteViews: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'views'); + }, + /** + * @see this.selectClass for parameters + */ + customizeWebsiteVariable: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'variable'); + }, + /** + * @see this.selectClass for parameters + */ + customizeWebsiteColor: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'color'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _checkIfWidgetsUpdateNeedReload(widgets) { + const needReload = await this._super(...arguments); + if (needReload) { + return needReload; + } + for (const widget of widgets) { + const methodsNames = widget.getMethodsNames(); + if (!methodsNames.includes('customizeWebsiteViews') + && !methodsNames.includes('customizeWebsiteVariable') + && !methodsNames.includes('customizeWebsiteColor')) { + continue; + } + let paramsReload = false; + if (widget.getMethodsParams('customizeWebsiteViews').reload + || widget.getMethodsParams('customizeWebsiteVariable').reload + || widget.getMethodsParams('customizeWebsiteColor').reload) { + paramsReload = true; + } + if (paramsReload || config.isDebug('assets')) { + return (config.isDebug('assets') ? _t("It appears you are in debug=assets mode, all theme customization options require a page reload in this mode.") : true); + } + } + return false; + }, + /** + * @override + */ + _computeWidgetState: async function (methodName, params) { + switch (methodName) { + case 'customizeWebsiteViews': { + const allXmlIDs = this._getXMLIDsFromPossibleValues(params.possibleValues); + const enabledXmlIDs = await this._rpc({ + route: '/website/theme_customize_get', + params: { + 'xml_ids': allXmlIDs, + }, + }); + let mostXmlIDsStr = ''; + let mostXmlIDsNb = 0; + for (const xmlIDsStr of params.possibleValues) { + const enableXmlIDs = xmlIDsStr.split(/\s*,\s*/); + if (enableXmlIDs.length > mostXmlIDsNb + && enableXmlIDs.every(xmlID => enabledXmlIDs.includes(xmlID))) { + mostXmlIDsStr = xmlIDsStr; + mostXmlIDsNb = enableXmlIDs.length; + } + } + return mostXmlIDsStr; // Need to return the exact same string as in possibleValues + } + case 'customizeWebsiteVariable': { + return weUtils.getCSSVariableValue(params.variable); + } + case 'customizeWebsiteColor': { + // TODO adapt in master + const bugfixedValue = weUtils.getCSSVariableValue(`bugfixed-${params.color}`); + if (bugfixedValue) { + return bugfixedValue; + } + return weUtils.getCSSVariableValue(params.color); + } + } + return this._super(...arguments); + }, + /** + * @private + */ + _customizeWebsite: async function (previewMode, widgetValue, params, type) { + // Never allow previews for theme customizations + if (previewMode) { + return; + } + + switch (type) { + case 'views': + await this._customizeWebsiteViews(widgetValue, params); + break; + case 'variable': + await this._customizeWebsiteVariable(widgetValue, params); + break; + case 'color': + await this._customizeWebsiteColor(widgetValue, params); + break; + } + + if (params.reload || config.isDebug('assets')) { + // Caller will reload the page, nothing needs to be done anymore. + return; + } + + // Finally, only update the bundles as no reload is required + await this._reloadBundles(); + + // Some public widgets may depend on the variables that were + // customized, so we have to restart them *all*. + await new Promise((resolve, reject) => { + this.trigger_up('widgets_start_request', { + editableMode: true, + onSuccess: () => resolve(), + onFailure: () => reject(), + }); + }); + }, + /** + * @private + */ + _customizeWebsiteColor: async function (color, params) { + const baseURL = '/website/static/src/scss/options/colors/'; + const colorType = params.colorType ? (params.colorType + '_') : ''; + const url = `${baseURL}user_${colorType}color_palette.scss`; + + if (color) { + if (weUtils.isColorCombinationName(color)) { + color = parseInt(color); + } else if (!ColorpickerWidget.isCSSColor(color)) { + color = `'${color}'`; + } + } + return this._makeSCSSCusto(url, {[params.color]: color}); + }, + /** + * @private + */ + _customizeWebsiteVariable: async function (value, params) { + return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', { + [params.variable]: value, + }); + }, + /** + * @private + */ + _customizeWebsiteViews: async function (xmlID, params) { + const allXmlIDs = this._getXMLIDsFromPossibleValues(params.possibleValues); + const enableXmlIDs = xmlID.split(/\s*,\s*/); + const disableXmlIDs = allXmlIDs.filter(xmlID => !enableXmlIDs.includes(xmlID)); + + return this._rpc({ + route: '/website/theme_customize', + params: { + 'enable': enableXmlIDs, + 'disable': disableXmlIDs, + }, + }); + }, + /** + * @private + */ + _getXMLIDsFromPossibleValues: function (possibleValues) { + const allXmlIDs = []; + for (const xmlIDsStr of possibleValues) { + allXmlIDs.push(...xmlIDsStr.split(/\s*,\s*/)); + } + return allXmlIDs.filter((v, i, arr) => arr.indexOf(v) === i); + }, + /** + * @private + */ + _makeSCSSCusto: async function (url, values) { + return this._rpc({ + route: '/website/make_scss_custo', + params: { + 'url': url, + 'values': _.mapObject(values, v => v || 'null'), + }, + }); + }, + /** + * Refreshes all public widgets related to the given element. + * + * @private + * @param {jQuery} [$el=this.$target] + * @returns {Promise} + */ + _refreshPublicWidgets: async function ($el) { + return new Promise((resolve, reject) => { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: $el || this.$target, + onSuccess: resolve, + onFailure: reject, + }); + }); + }, + /** + * @private + */ + _reloadBundles: async function () { + const bundles = await this._rpc({ + route: '/website/theme_customize_bundle_reload', + }); + let $allLinks = $(); + const proms = _.map(bundles, (bundleURLs, bundleName) => { + var $links = $('link[href*="' + bundleName + '"]'); + $allLinks = $allLinks.add($links); + var $newLinks = $(); + _.each(bundleURLs, url => { + $newLinks = $newLinks.add($('<link/>', { + type: 'text/css', + rel: 'stylesheet', + href: url, + })); + }); + + const linksLoaded = new Promise(resolve => { + let nbLoaded = 0; + $newLinks.on('load error', () => { // If we have an error, just ignore it + if (++nbLoaded >= $newLinks.length) { + resolve(); + } + }); + }); + $links.last().after($newLinks); + return linksLoaded; + }); + await Promise.all(proms).then(() => $allLinks.remove()); + }, + /** + * @override + */ + _select: async function (previewMode, widget) { + await this._super(...arguments); + + if (!widget.$el.closest('[data-no-widget-refresh="true"]').length) { + // TODO the flag should be retrieved through widget params somehow + await this._refreshPublicWidgets(); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onGoogleFontsCustoRequest: function (ev) { + const values = ev.data.values ? _.clone(ev.data.values) : {}; + const googleFonts = ev.data.googleFonts; + if (googleFonts.length) { + values['google-fonts'] = "('" + googleFonts.join("', '") + "')"; + } else { + values['google-fonts'] = 'null'; + } + this.trigger_up('snippet_edition_request', {exec: async () => { + return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values); + }}); + this.trigger_up('request_save', { + reloadEditor: true, + }); + }, +}); + +function _getLastPreFilterLayerElement($el) { + // Make sure parallax and video element are considered to be below the + // color filters / shape + const $bgVideo = $el.find('> .o_bg_video_container'); + if ($bgVideo.length) { + return $bgVideo[0]; + } + const $parallaxEl = $el.find('> .s_parallax_bg'); + if ($parallaxEl.length) { + return $parallaxEl[0]; + } + return null; +} + +options.registry.BackgroundToggler.include({ + /** + * Toggles background video on or off. + * + * @see this.selectClass for parameters + */ + toggleBgVideo(previewMode, widgetValue, params) { + if (!widgetValue) { + // TODO: use setWidgetValue instead of calling background directly when possible + const [bgVideoWidget] = this._requestUserValueWidgets('bg_video_opt'); + const bgVideoOpt = bgVideoWidget.getParent(); + return bgVideoOpt._setBgVideo(false, ''); + } else { + // TODO: use trigger instead of el.click when possible + this._requestUserValueWidgets('bg_video_opt')[0].el.click(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'toggleBgVideo') { + return this.$target[0].classList.contains('o_background_video'); + } + return this._super(...arguments); + }, + /** + * TODO an overall better management of background layers is needed + * + * @override + */ + _getLastPreFilterLayerElement() { + const el = _getLastPreFilterLayerElement(this.$target); + if (el) { + return el; + } + return this._super(...arguments); + }, +}); + +options.registry.BackgroundShape.include({ + /** + * TODO need a better management of background layers + * + * @override + */ + _getLastPreShapeLayerElement() { + const el = this._super(...arguments); + if (el) { + return el; + } + return _getLastPreFilterLayerElement(this.$target); + } +}); + +options.registry.BackgroundVideo = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Sets the target's background video. + * + * @see this.selectClass for parameters + */ + background: function (previewMode, widgetValue, params) { + if (previewMode === 'reset' && this.videoSrc) { + return this._setBgVideo(false, this.videoSrc); + } + return this._setBgVideo(previewMode, widgetValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'background') { + if (this.$target[0].classList.contains('o_background_video')) { + return this.$('> .o_bg_video_container iframe').attr('src'); + } + return ''; + } + return this._super(...arguments); + }, + /** + * Updates the background video used by the snippet. + * + * @private + * @see this.selectClass for parameters + * @returns {Promise} + */ + _setBgVideo: async function (previewMode, value) { + this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true); + + if (previewMode !== false) { + return; + } + + this.videoSrc = value; + var target = this.$target[0]; + target.classList.toggle('o_background_video', !!(value && value.length)); + if (value && value.length) { + target.dataset.bgVideoSrc = value; + } else { + delete target.dataset.bgVideoSrc; + } + await this._refreshPublicWidgets(); + }, +}); + +options.registry.OptionsTab = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + async configureApiKey(previewMode, widgetValue, params) { + return new Promise(resolve => { + this.trigger_up('gmap_api_key_request', { + editableMode: true, + reconfigure: true, + onSuccess: () => resolve(), + }); + }); + }, + /** + * @see this.selectClass for parameters + */ + async customizeBodyBgType(previewMode, widgetValue, params) { + if (widgetValue === 'NONE') { + this.bodyImageType = 'image'; + return this.customizeBodyBg(previewMode, '', params); + } + // TODO improve: hack to click on external image picker + this.bodyImageType = widgetValue; + const widget = this._requestUserValueWidgets(params.imagepicker)[0]; + widget.enable(); + }, + /** + * @override + */ + async customizeBodyBg(previewMode, widgetValue, params) { + // TODO improve: customize two variables at the same time... + await this.customizeWebsiteVariable(previewMode, this.bodyImageType, {variable: 'body-image-type'}); + await this.customizeWebsiteVariable(previewMode, widgetValue ? `'${widgetValue}'` : '', {variable: 'body-image'}); + }, + /** + * @see this.selectClass for parameters + */ + async openCustomCodeDialog(previewMode, widgetValue, params) { + const libsProm = this._loadLibs({ + jsLibs: [ + '/web/static/lib/ace/ace.js', + '/web/static/lib/ace/mode-xml.js', + ], + }); + + let websiteId; + this.trigger_up('context_get', { + callback: (ctx) => { + websiteId = ctx['website_id']; + }, + }); + + let website; + const dataProm = this._rpc({ + model: 'website', + method: 'read', + args: [[websiteId], ['custom_code_head', 'custom_code_footer']], + }).then(websites => { + website = websites[0]; + }); + + let fieldName, title, contentText; + if (widgetValue === 'head') { + fieldName = 'custom_code_head'; + title = _t('Custom head code'); + contentText = _t('Enter code that will be added into the <head> of every page of your site.'); + } else { + fieldName = 'custom_code_footer'; + title = _t('Custom end of body code'); + contentText = _t('Enter code that will be added before the </body> of every page of your site.'); + } + + await Promise.all([libsProm, dataProm]); + + await new Promise(resolve => { + const $content = $(core.qweb.render('website.custom_code_dialog_content', { + contentText, + })); + const aceEditor = this._renderAceEditor($content.find('.o_ace_editor_container')[0], website[fieldName] || ''); + const dialog = new Dialog(this, { + title, + $content, + buttons: [ + { + text: _t("Save"), + classes: 'btn-primary', + click: async () => { + await this._rpc({ + model: 'website', + method: 'write', + args: [ + [websiteId], + {[fieldName]: aceEditor.getValue()}, + ], + }); + }, + close: true, + }, + { + text: _t("Discard"), + close: true, + }, + ], + }); + dialog.on('closed', this, resolve); + dialog.open(); + }); + }, + /** + * @see this.selectClass for parameters + */ + async switchTheme(previewMode, widgetValue, params) { + const save = await new Promise(resolve => { + Dialog.confirm(this, _t("Changing theme requires to leave the editor. This will save all your changes, are you sure you want to proceed? Be careful that changing the theme will reset all your color customizations."), { + confirm_callback: () => resolve(true), + cancel_callback: () => resolve(false), + }); + }); + if (!save) { + return; + } + this.trigger_up('request_save', { + reload: false, + onSuccess: () => window.location.href = '/web#action=website.theme_install_kanban_action', + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _checkIfWidgetsUpdateNeedWarning(widgets) { + const warningMessage = await this._super(...arguments); + if (warningMessage) { + return warningMessage; + } + for (const widget of widgets) { + if (widget.getMethodsNames().includes('customizeWebsiteVariable') + && widget.getMethodsParams('customizeWebsiteVariable').variable === 'color-palettes-number') { + const hasCustomizedColors = weUtils.getCSSVariableValue('has-customized-colors'); + if (hasCustomizedColors && hasCustomizedColors !== 'false') { + return _t("Changing the color palette will reset all your color customizations, are you sure you want to proceed?"); + } + } + } + return ''; + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'customizeBodyBgType') { + const bgImage = $('#wrapwrap').css('background-image'); + if (bgImage === 'none') { + return "NONE"; + } + return weUtils.getCSSVariableValue('body-image-type'); + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'body_bg_image_opt') { + return false; + } + return this._super(...arguments); + }, + /** + * @private + * @param {DOMElement} node + * @param {String} content text of the editor + * @returns {Object} + */ + _renderAceEditor(node, content) { + const aceEditor = window.ace.edit(node); + aceEditor.setTheme('ace/theme/monokai'); + aceEditor.setValue(content, 1); + aceEditor.setOptions({ + minLines: 20, + maxLines: Infinity, + showPrintMargin: false, + }); + aceEditor.renderer.setOptions({ + highlightGutterLine: true, + showInvisibles: true, + fontSize: 14, + }); + + const aceSession = aceEditor.getSession(); + aceSession.setOptions({ + mode: "ace/mode/xml", + useWorker: false, + }); + return aceEditor; + }, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + uiFragment.querySelectorAll('we-colorpicker').forEach(el => { + el.dataset.lazyPalette = 'true'; + }); + }, +}); + +options.registry.ThemeColors = options.registry.OptionsTab.extend({ + /** + * @override + */ + async start() { + // Checks for support of the old color system + const style = window.getComputedStyle(document.documentElement); + const supportOldColorSystem = weUtils.getCSSVariableValue('support-13-0-color-system', style) === 'true'; + const hasCustomizedOldColorSystem = weUtils.getCSSVariableValue('has-customized-13-0-color-system', style) === 'true'; + this._showOldColorSystemWarning = supportOldColorSystem && hasCustomizedOldColorSystem; + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUIVisibility() { + await this._super(...arguments); + const oldColorSystemEl = this.el.querySelector('.o_old_color_system_warning'); + oldColorSystemEl.classList.toggle('d-none', !this._showOldColorSystemWarning); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _renderCustomXML(uiFragment) { + const paletteSelectorEl = uiFragment.querySelector('[data-variable="color-palettes-number"]'); + const style = window.getComputedStyle(document.documentElement); + const nbPalettes = parseInt(weUtils.getCSSVariableValue('number-of-color-palettes', style)); + for (let i = 1; i <= nbPalettes; i++) { + const btnEl = document.createElement('we-button'); + btnEl.classList.add('o_palette_color_preview_button'); + btnEl.dataset.customizeWebsiteVariable = i; + for (let c = 1; c <= 5; c++) { + const colorPreviewEl = document.createElement('span'); + colorPreviewEl.classList.add('o_palette_color_preview'); + const color = weUtils.getCSSVariableValue(`o-palette-${i}-o-color-${c}`, style); + colorPreviewEl.style.backgroundColor = color; + btnEl.appendChild(colorPreviewEl); + } + paletteSelectorEl.appendChild(btnEl); + } + + for (let i = 1; i <= 5; i++) { + const collapseEl = document.createElement('we-collapse'); + const ccPreviewEl = $(qweb.render('web_editor.color.combination.preview'))[0]; + ccPreviewEl.classList.add('text-center', `o_cc${i}`); + collapseEl.appendChild(ccPreviewEl); + const editionEls = $(qweb.render('website.color_combination_edition', {number: i})); + for (const el of editionEls) { + collapseEl.appendChild(el); + } + uiFragment.appendChild(collapseEl); + } + + await this._super(...arguments); + }, +}); + +options.registry.menu_data = options.Class.extend({ + /** + * When the users selects a menu, a dialog is opened to ask him if he wants + * to follow the link (and leave editor), edit the menu or do nothing. + * + * @override + */ + onFocus: function () { + var self = this; + (new Dialog(this, { + title: _t("Confirmation"), + $content: $(core.qweb.render('website.leaving_current_page_edition')), + buttons: [ + {text: _t("Go to Link"), classes: 'btn-primary', click: function () { + self.trigger_up('request_save', { + reload: false, + onSuccess: function () { + window.location.href = self.$target.attr('href'); + }, + }); + }}, + {text: _t("Edit the menu"), classes: 'btn-primary', click: function () { + this.trigger_up('action_demand', { + actionName: 'edit_menu', + params: [ + function () { + var prom = new Promise(function (resolve, reject) { + self.trigger_up('request_save', { + onSuccess: resolve, + onFailure: reject, + }); + }); + return prom; + }, + ], + }); + }}, + {text: _t("Stay on this page"), close: true} + ] + })).open(); + }, +}); + +options.registry.company_data = options.Class.extend({ + /** + * Fetches data to determine the URL where the user can edit its company + * data. Saves the info in the prototype to do this only once. + * + * @override + */ + start: function () { + var proto = options.registry.company_data.prototype; + var prom; + var self = this; + if (proto.__link === undefined) { + prom = this._rpc({route: '/web/session/get_session_info'}).then(function (session) { + return self._rpc({ + model: 'res.users', + method: 'read', + args: [session.uid, ['company_id']], + }); + }).then(function (res) { + proto.__link = '/web#action=base.action_res_company_form&view_type=form&id=' + (res && res[0] && res[0].company_id[0] || 1); + }); + } + return Promise.all([this._super.apply(this, arguments), prom]); + }, + /** + * When the users selects company data, opens a dialog to ask him if he + * wants to be redirected to the company form view to edit it. + * + * @override + */ + onFocus: function () { + var self = this; + var proto = options.registry.company_data.prototype; + + Dialog.confirm(this, _t("Do you want to edit the company data ?"), { + confirm_callback: function () { + self.trigger_up('request_save', { + reload: false, + onSuccess: function () { + window.location.href = proto.__link; + }, + }); + }, + }); + }, +}); + +options.registry.Carousel = options.Class.extend({ + /** + * @override + */ + start: function () { + this.$target.carousel('pause'); + this.$indicators = this.$target.find('.carousel-indicators'); + this.$controls = this.$target.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + + // Prevent enabling the carousel overlay when clicking on the carousel + // controls (indeed we want it to change the carousel slide then enable + // the slide overlay) + See "CarouselItem" option. + this.$controls.addClass('o_we_no_overlay'); + + let _slideTimestamp; + this.$target.on('slide.bs.carousel.carousel_option', () => { + _slideTimestamp = window.performance.now(); + setTimeout(() => this.trigger_up('hide_overlay')); + }); + this.$target.on('slid.bs.carousel.carousel_option', () => { + // slid.bs.carousel is most of the time fired too soon by bootstrap + // since it emulates the transitionEnd with a setTimeout. We wait + // here an extra 20% of the time before retargeting edition, which + // should be enough... + const _slideDuration = (window.performance.now() - _slideTimestamp); + setTimeout(() => { + this.trigger_up('activate_snippet', { + $snippet: this.$target.find('.carousel-item.active'), + ifInactiveOptions: true, + }); + this.$target.trigger('active_slide_targeted'); + }, 0.2 * _slideDuration); + }); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$target.off('.carousel_option'); + }, + /** + * @override + */ + onBuilt: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + onClone: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + cleanForSave: function () { + const $items = this.$target.find('.carousel-item'); + $items.removeClass('next prev left right active').first().addClass('active'); + this.$indicators.find('li').removeClass('active').empty().first().addClass('active'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates a unique ID for the carousel and reassign data-attributes that + * depend on it. + * + * @private + */ + _assignUniqueID: function () { + const id = 'myCarousel' + Date.now(); + this.$target.attr('id', id); + this.$target.find('[data-target]').attr('data-target', '#' + id); + _.each(this.$target.find('[data-slide], [data-slide-to]'), function (el) { + var $el = $(el); + if ($el.attr('data-target')) { + $el.attr('data-target', '#' + id); + } else if ($el.attr('href')) { + $el.attr('href', '#' + id); + } + }); + }, +}); + +options.registry.CarouselItem = options.Class.extend({ + isTopOption: true, + forceNoDeleteButton: true, + + /** + * @override + */ + start: function () { + this.$carousel = this.$target.closest('.carousel'); + this.$indicators = this.$carousel.find('.carousel-indicators'); + this.$controls = this.$carousel.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + + var leftPanelEl = this.$overlay.data('$optionsSection')[0]; + var titleTextEl = leftPanelEl.querySelector('we-title > span'); + this.counterEl = document.createElement('span'); + titleTextEl.appendChild(this.counterEl); + + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._super(...arguments); + this.$carousel.off('.carousel_item_option'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Updates the slide counter. + * + * @override + */ + updateUI: async function () { + await this._super(...arguments); + const $items = this.$carousel.find('.carousel-item'); + const $activeSlide = $items.filter('.active'); + const updatedText = ` (${$activeSlide.index() + 1}/${$items.length})`; + this.counterEl.textContent = updatedText; + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Adds a slide. + * + * @see this.selectClass for parameters + */ + addSlide: function (previewMode) { + const $items = this.$carousel.find('.carousel-item'); + this.$controls.removeClass('d-none'); + this.$indicators.append($('<li>', { + 'data-target': '#' + this.$carousel.attr('id'), + 'data-slide-to': $items.length, + })); + this.$indicators.append(' '); + // Need to remove editor data from the clone so it gets its own. + const $active = $items.filter('.active'); + $active.clone(false) + .removeClass('active') + .insertAfter($active); + this.$carousel.carousel('next'); + }, + /** + * Removes the current slide. + * + * @see this.selectClass for parameters. + */ + removeSlide: function (previewMode) { + const $items = this.$carousel.find('.carousel-item'); + const newLength = $items.length - 1; + if (!this.removing && newLength > 0) { + const $toDelete = $items.filter('.active'); + this.$carousel.one('active_slide_targeted.carousel_item_option', () => { + $toDelete.remove(); + this.$indicators.find('li:last').remove(); + this.$controls.toggleClass('d-none', newLength === 1); + this.$carousel.trigger('content_changed'); + this.removing = false; + }); + this.removing = true; + this.$carousel.carousel('prev'); + } + }, + /** + * Goes to next slide or previous slide. + * + * @see this.selectClass for parameters + */ + slide: function (previewMode, widgetValue, params) { + switch (widgetValue) { + case 'left': + this.$controls.filter('.carousel-control-prev')[0].click(); + break; + case 'right': + this.$controls.filter('.carousel-control-next')[0].click(); + break; + } + }, +}); + +options.registry.sizing_x = options.registry.sizing.extend({ + /** + * @override + */ + onClone: function (options) { + this._super.apply(this, arguments); + // Below condition is added to remove offset of target element only + // and not its children to avoid design alteration of a container/block. + if (options.isCurrent) { + var _class = this.$target.attr('class').replace(/\s*(offset-xl-|offset-lg-)([0-9-]+)/g, ''); + this.$target.attr('class', _class); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getSize: function () { + var width = this.$target.closest('.row').width(); + var gridE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + var gridW = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + this.grid = { + e: [_.map(gridE, v => ('col-lg-' + v)), _.map(gridE, v => width / 12 * v), 'width'], + w: [_.map(gridW, v => ('offset-lg-' + v)), _.map(gridW, v => width / 12 * v), 'margin-left'], + }; + return this.grid; + }, + /** + * @override + */ + _onResize: function (compass, beginClass, current) { + if (compass === 'w') { + // don't change the right border position when we change the offset (replace col size) + var beginCol = Number(beginClass.match(/col-lg-([0-9]+)|$/)[1] || 0); + var beginOffset = Number(beginClass.match(/offset-lg-([0-9-]+)|$/)[1] || beginClass.match(/offset-xl-([0-9-]+)|$/)[1] || 0); + var offset = Number(this.grid.w[0][current].match(/offset-lg-([0-9-]+)|$/)[1] || 0); + if (offset < 0) { + offset = 0; + } + var colSize = beginCol - (offset - beginOffset); + if (colSize <= 0) { + colSize = 1; + offset = beginOffset + beginCol - 1; + } + this.$target.attr('class', this.$target.attr('class').replace(/\s*(offset-xl-|offset-lg-|col-lg-)([0-9-]+)/g, '')); + + this.$target.addClass('col-lg-' + (colSize > 12 ? 12 : colSize)); + if (offset > 0) { + this.$target.addClass('offset-lg-' + offset); + } + } + this._super.apply(this, arguments); + }, +}); + +options.registry.layout_column = options.Class.extend({ + /** + * @override + */ + start: function () { + // Needs to be done manually for now because _computeWidgetVisibility + // doesn't go through this option for buttons inside of a select. + // TODO: improve this. + this.$el.find('we-button[data-name="zero_cols_opt"]') + .toggleClass('d-none', !this.$target.is('.s_allow_columns')); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Changes the number of columns. + * + * @see this.selectClass for parameters + */ + selectCount: async function (previewMode, widgetValue, params) { + const previousNbColumns = this.$('> .row').children().length; + let $row = this.$('> .row'); + if (!$row.length) { + $row = this.$target.contents().wrapAll($('<div class="row"><div class="col-lg-12"/></div>')).parent().parent(); + } + + const nbColumns = parseInt(widgetValue); + await this._updateColumnCount($row, (nbColumns || 1) - $row.children().length); + // Yield UI thread to wait for event to bubble before activate_snippet is called. + // In this case this lets the select handle the click event before we switch snippet. + // TODO: make this more generic in activate_snippet event handler. + await new Promise(resolve => setTimeout(resolve)); + if (nbColumns === 0) { + $row.contents().unwrap().contents().unwrap(); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } else if (previousNbColumns === 0) { + this.trigger_up('activate_snippet', {$snippet: this.$('> .row').children().first()}); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'selectCount') { + return this.$('> .row').children().length; + } + return this._super(...arguments); + }, + /** + * Adds new columns which are clones of the last column or removes the + * last x columns. + * + * @private + * @param {jQuery} $row - the row in which to update the columns + * @param {integer} count - positif to add, negative to remove + */ + _updateColumnCount: async function ($row, count) { + if (!count) { + return; + } + + if (count > 0) { + var $lastColumn = $row.children().last(); + for (var i = 0; i < count; i++) { + await new Promise(resolve => { + this.trigger_up('clone_snippet', {$snippet: $lastColumn, onSuccess: resolve}); + }); + } + } else { + var self = this; + for (const el of $row.children().slice(count)) { + await new Promise(resolve => { + self.trigger_up('remove_snippet', {$snippet: $(el), onSuccess: resolve}); + }); + } + } + + this._resizeColumns($row.children()); + this.trigger_up('cover_update'); + }, + /** + * Resizes the columns so that they are kept on one row. + * + * @private + * @param {jQuery} $columns - the columns to resize + */ + _resizeColumns: function ($columns) { + const colsLength = $columns.length; + var colSize = Math.floor(12 / colsLength) || 1; + var colOffset = Math.floor((12 - colSize * colsLength) / 2); + var colClass = 'col-lg-' + colSize; + _.each($columns, function (column) { + var $column = $(column); + $column.attr('class', $column.attr('class').replace(/\b(col|offset)-lg(-\d+)?\b/g, '')); + $column.addClass(colClass); + }); + if (colOffset) { + $columns.first().addClass('offset-lg-' + colOffset); + } + }, +}); + +options.registry.Parallax = options.Class.extend({ + /** + * @override + */ + async start() { + this.parallaxEl = this.$target.find('> .s_parallax_bg')[0] || null; + this._updateBackgroundOptions(); + + this.$target.on('content_changed.ParallaxOption', this._onExternalUpdate.bind(this)); + + return this._super(...arguments); + }, + /** + * @override + */ + onFocus() { + // Refresh the parallax animation on focus; at least useful because + // there may have been changes in the page that influenced the parallax + // rendering (new snippets, ...). + // TODO make this automatic. + if (this.parallaxEl) { + this._refreshPublicWidgets(); + } + }, + /** + * @override + */ + onMove() { + this._refreshPublicWidgets(); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this.$target.off('.ParallaxOption'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Build/remove parallax. + * + * @see this.selectClass for parameters + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await this._super(...arguments); + if (params.attributeName !== 'scrollBackgroundRatio') { + return; + } + + const isParallax = (widgetValue !== '0'); + this.$target.toggleClass('parallax', isParallax); + this.$target.toggleClass('s_parallax_is_fixed', widgetValue === '1'); + this.$target.toggleClass('s_parallax_no_overflow_hidden', (widgetValue === '0' || widgetValue === '1')); + if (isParallax) { + if (!this.parallaxEl) { + this.parallaxEl = document.createElement('span'); + this.parallaxEl.classList.add('s_parallax_bg'); + this.$target.prepend(this.parallaxEl); + } + } else { + if (this.parallaxEl) { + this.parallaxEl.remove(); + this.parallaxEl = null; + } + } + + this._updateBackgroundOptions(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeVisibility(widgetName) { + return !this.$target.hasClass('o_background_video'); + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'selectDataAttribute' && params.parallaxTypeOpt) { + const attrName = params.attributeName; + const attrValue = (this.$target[0].dataset[attrName] || params.attributeDefaultValue).trim(); + switch (attrValue) { + case '0': + case '1': { + return attrValue; + } + default: { + return (attrValue.startsWith('-') ? '-1.5' : '1.5'); + } + } + } + return this._super(...arguments); + }, + /** + * Updates external background-related option to work with the parallax + * element instead of the original target when necessary. + * + * @private + */ + _updateBackgroundOptions() { + this.trigger_up('option_update', { + optionNames: ['BackgroundImage', 'BackgroundPosition', 'BackgroundOptimize'], + name: 'target', + data: this.parallaxEl ? $(this.parallaxEl) : this.$target, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called on any snippet update to check if the parallax should still be + * enabled or not. + * + * TODO there is probably a better system to implement to solve this issue. + * + * @private + * @param {Event} ev + */ + _onExternalUpdate(ev) { + if (!this.parallaxEl) { + return; + } + const bgImage = this.parallaxEl.style.backgroundImage; + if (!bgImage || bgImage === 'none' || this.$target.hasClass('o_background_video')) { + // The parallax option was enabled but the background image was + // removed: disable the parallax option. + const widget = this._requestUserValueWidgets('parallax_none_opt')[0]; + widget.enable(); + widget.getParent().close(); // FIXME remove this ugly hack asap + } + }, +}); + +options.registry.collapse = options.Class.extend({ + /** + * @override + */ + start: function () { + var self = this; + this.$target.on('shown.bs.collapse hidden.bs.collapse', '[role="tabpanel"]', function () { + self.trigger_up('cover_update'); + self.$target.trigger('content_changed'); + }); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt: function () { + this._createIDs(); + }, + /** + * @override + */ + onClone: function () { + this._createIDs(); + }, + /** + * @override + */ + onMove: function () { + this._createIDs(); + var $panel = this.$target.find('.collapse').removeData('bs.collapse'); + if ($panel.attr('aria-expanded') === 'true') { + $panel.closest('.accordion').find('.collapse[aria-expanded="true"]') + .filter((i, el) => (el !== $panel[0])) + .collapse('hide') + .one('hidden.bs.collapse', function () { + $panel.trigger('shown.bs.collapse'); + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Associates unique ids on collapse elements. + * + * @private + */ + _createIDs: function () { + let time = new Date().getTime(); + const $tablist = this.$target.closest('[role="tablist"]'); + const $tab = this.$target.find('[role="tab"]'); + const $panel = this.$target.find('[role="tabpanel"]'); + + const setUniqueId = ($elem, label) => { + let elemId = $elem.attr('id'); + if (!elemId || $('[id="' + elemId + '"]').length > 1) { + do { + time++; + elemId = label + time; + } while ($('#' + elemId).length); + $elem.attr('id', elemId); + } + return elemId; + }; + + const tablistId = setUniqueId($tablist, 'myCollapse'); + $panel.attr('data-parent', '#' + tablistId); + $panel.data('parent', '#' + tablistId); + + const panelId = setUniqueId($panel, 'myCollapseTab'); + $tab.attr('data-target', '#' + panelId); + $tab.data('target', '#' + panelId); + }, +}); + +options.registry.HeaderNavbar = options.Class.extend({ + /** + * Particular case: we want the option to be associated on the header navbar + * in XML so that the related options only appear on navbar click (not + * header), in a different section, etc... but we still want the target to + * be the header itself. + * + * @constructor + */ + init() { + this._super(...arguments); + // Don't use setTarget, we want it to be set directly at initialization. + this.$target = this.$target.closest('#wrapwrap > header'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Needs to be done manually for now because data-dependencies + * doesn't work with "AND" conditions. + * TODO: improve this. + * + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'option_logo_height_scrolled') { + return !this.$('.navbar-brand').hasClass('d-none'); + } + return this._super(...arguments); + }, +}); + +const VisibilityPageOptionUpdate = options.Class.extend({ + pageOptionName: undefined, + showOptionWidgetName: undefined, + shownValue: '', + + /** + * @override + */ + async start() { + await this._super(...arguments); + const shown = await this._isShown(); + this.trigger_up('snippet_option_visibility_update', {show: shown}); + }, + /** + * @override + */ + async onTargetShow() { + if (await this._isShown()) { + // onTargetShow may be called even if the element is already shown. + // In most cases, this is not a problem but here it is as the code + // that follows clicks on the visibility checkbox regardless of its + // status. This avoids searching for that checkbox entirely. + return; + } + // TODO improve: here we make a hack so that if we make the invisible + // header appear for edition, its actual visibility for the page is + // toggled (otherwise it would be about editing an element which + // is actually never displayed on the page). + const widget = this._requestUserValueWidgets(this.showOptionWidgetName)[0]; + widget.enable(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for params + */ + async visibility(previewMode, widgetValue, params) { + const show = (widgetValue !== 'hidden'); + await new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: this.pageOptionName, value: show}], + onSuccess: () => resolve(), + }); + }); + this.trigger_up('snippet_option_visibility_update', {show: show}); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'visibility') { + const shown = await this._isShown(); + return shown ? this.shownValue : 'hidden'; + } + return this._super(...arguments); + }, + /** + * @private + * @returns {boolean} + */ + async _isShown() { + return new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: [this.pageOptionName], + onSuccess: v => resolve(!!v), + }); + }); + }, +}); + +options.registry.TopMenuVisibility = VisibilityPageOptionUpdate.extend({ + pageOptionName: 'header_visible', + showOptionWidgetName: 'regular_header_visibility_opt', + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles the switching between 3 differents visibilities of the header. + * + * @see this.selectClass for params + */ + async visibility(previewMode, widgetValue, params) { + await this._super(...arguments); + await this._changeVisibility(widgetValue); + // TODO this is hacky but changing the header visibility may have an + // effect on features like FullScreenHeight which depend on viewport + // size so we simulate a resize. + $(window).trigger('resize'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _changeVisibility(widgetValue) { + const show = (widgetValue !== 'hidden'); + if (!show) { + return; + } + const transparent = (widgetValue === 'transparent'); + await new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_overlay', value: transparent}], + onSuccess: () => resolve(), + }); + }); + if (!transparent) { + return; + } + await new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_color', value: ''}], + onSuccess: () => resolve(), + }); + }); + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + const _super = this._super.bind(this); + if (methodName === 'visibility') { + this.shownValue = await new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: v => resolve(v ? 'transparent' : 'regular'), + }); + }); + } + return _super(...arguments); + }, +}); + +options.registry.topMenuColor = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + selectStyle(previewMode, widgetValue, params) { + this._super(...arguments); + const className = widgetValue ? (params.colorPrefix + widgetValue) : ''; + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_color', value: className}], + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeVisibility: async function () { + const show = await this._super(...arguments); + if (!show) { + return false; + } + return new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: value => resolve(!!value), + }); + }); + }, +}); + +/** + * Manage the visibility of snippets on mobile. + */ +options.registry.MobileVisibility = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Allows to show or hide the associated snippet in mobile display mode. + * + * @see this.selectClass for parameters + */ + showOnMobile(previewMode, widgetValue, params) { + const classes = `d-none d-md-${this.$target.css('display')}`; + this.$target.toggleClass(classes, !widgetValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'showOnMobile') { + const classList = [...this.$target[0].classList]; + return classList.includes('d-none') && + classList.some(className => className.startsWith('d-md-')) ? '' : 'true'; + } + return await this._super(...arguments); + }, +}); + +/** + * Hide/show footer in the current page. + */ +options.registry.HideFooter = VisibilityPageOptionUpdate.extend({ + pageOptionName: 'footer_visible', + showOptionWidgetName: 'hide_footer_page_opt', + shownValue: 'shown', +}); + +/** + * Handles the edition of snippet's anchor name. + */ +options.registry.anchor = options.Class.extend({ + isTopOption: true, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + start: function () { + // Generate anchor and copy it to clipboard on click, show the tooltip on success + this.$button = this.$el.find('we-button'); + const clipboard = new ClipboardJS(this.$button[0], {text: () => this._getAnchorLink()}); + clipboard.on('success', () => { + const anchor = decodeURIComponent(this._getAnchorLink()); + this.displayNotification({ + type: 'success', + message: _.str.sprintf(_t("Anchor copied to clipboard<br>Link: %s"), anchor), + buttons: [{text: _t("Edit"), click: () => this.openAnchorDialog(), primary: true}], + }); + }); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onClone: function () { + this.$target.removeAttr('data-anchor'); + this.$target.filter(':not(.carousel)').removeAttr('id'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** + * @see this.selectClass for parameters + */ + openAnchorDialog: function (previewMode, widgetValue, params) { + var self = this; + var buttons = [{ + text: _t("Save & copy"), + classes: 'btn-primary', + click: function () { + var $input = this.$('.o_input_anchor_name'); + var anchorName = self._text2Anchor($input.val()); + if (self.$target[0].id === anchorName) { + // If the chosen anchor name is already the one used by the + // element, close the dialog and do nothing else + this.close(); + return; + } + + const alreadyExists = !!document.getElementById(anchorName); + this.$('.o_anchor_already_exists').toggleClass('d-none', !alreadyExists); + $input.toggleClass('is-invalid', alreadyExists); + if (!alreadyExists) { + self._setAnchorName(anchorName); + this.close(); + self.$button[0].click(); + } + }, + }, { + text: _t("Discard"), + close: true, + }]; + if (this.$target.attr('id')) { + buttons.push({ + text: _t("Remove"), + classes: 'btn-link ml-auto', + icon: 'fa-trash', + close: true, + click: function () { + self._setAnchorName(); + }, + }); + } + new Dialog(this, { + title: _t("Link Anchor"), + $content: $(qweb.render('website.dialog.anchorName', { + currentAnchor: decodeURIComponent(this.$target.attr('id')), + })), + buttons: buttons, + }).open(); + }, + /** + * @private + * @param {String} value + */ + _setAnchorName: function (value) { + if (value) { + this.$target.attr({ + 'id': value, + 'data-anchor': true, + }); + } else { + this.$target.removeAttr('id data-anchor'); + } + this.$target.trigger('content_changed'); + }, + /** + * Returns anchor text. + * + * @private + * @returns {string} + */ + _getAnchorLink: function () { + if (!this.$target[0].id) { + const $titles = this.$target.find('h1, h2, h3, h4, h5, h6'); + const title = $titles.length > 0 ? $titles[0].innerText : this.data.snippetName; + const anchorName = this._text2Anchor(title); + let n = ''; + while (document.getElementById(anchorName + n)) { + n = (n || 1) + 1; + } + this._setAnchorName(anchorName + n); + } + return `${window.location.pathname}#${this.$target[0].id}`; + }, + /** + * Creates a safe id/anchor from text. + * + * @private + * @param {string} text + * @returns {string} + */ + _text2Anchor: function (text) { + return encodeURIComponent(text.trim().replace(/\s+/g, '-')); + }, +}); + +/** + * Controls box properties. + */ +options.registry.Box = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + setShadow(previewMode, widgetValue, params) { + this.$target.toggleClass(params.shadowClass, !!widgetValue); + const defaultShadow = this._getDefaultShadow(widgetValue, params.shadowClass); + this.$target[0].style.setProperty('box-shadow', defaultShadow, 'important'); + if (widgetValue === 'outset') { + // In this case, the shadowClass is enough + this.$target[0].style.setProperty('box-shadow', ''); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'setShadow') { + const shadowValue = this.$target.css('box-shadow'); + if (!shadowValue || shadowValue === 'none') { + return ''; + } + return this.$target.css('box-shadow').includes('inset') ? 'inset' : 'outset'; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'fake_inset_shadow_opt') { + return false; + } + return this._super(...arguments); + }, + /** + * @private + * @param {string} type + * @param {string} shadowClass + * @returns {string} + */ + _getDefaultShadow(type, shadowClass) { + const el = document.createElement('div'); + if (type) { + el.classList.add(shadowClass); + } + document.body.appendChild(el); + switch (type) { + case 'outset': { + return $(el).css('box-shadow'); + } + case 'inset': { + return $(el).css('box-shadow') + ' inset'; + } + } + el.remove(); + return ''; + } +}); + +options.registry.HeaderBox = options.registry.Box.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + if ((params.variable || params.color) + && ['border-width', 'border-style', 'border-color', 'border-radius', 'box-shadow'].includes(params.cssProperty)) { + if (previewMode) { + return; + } + if (params.cssProperty === 'border-color') { + return this.customizeWebsiteColor(previewMode, widgetValue, params); + } + return this.customizeWebsiteVariable(previewMode, widgetValue, params); + } + return this._super(...arguments); + }, + /** + * @override + */ + async setShadow(previewMode, widgetValue, params) { + if (params.variable) { + if (previewMode) { + return; + } + const defaultShadow = this._getDefaultShadow(widgetValue, params.shadowClass); + return this.customizeWebsiteVariable(previewMode, defaultShadow || 'none', params); + } + return this._super(...arguments); + }, +}); + +options.registry.CookiesBar = options.registry.SnippetPopup.extend({ + xmlDependencies: (options.registry.SnippetPopup.prototype.xmlDependencies || []).concat( + ['/website/static/src/xml/website.cookies_bar.xml'] + ), + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Change the cookies bar layout. + * + * @see this.selectClass for parameters + */ + selectLayout: function (previewMode, widgetValue, params) { + let websiteId; + this.trigger_up('context_get', { + callback: function (ctx) { + websiteId = ctx['website_id']; + }, + }); + + const $template = $(qweb.render(`website.cookies_bar.${widgetValue}`, { + websiteId: websiteId, + })); + + const $content = this.$target.find('.modal-content'); + const selectorsToKeep = [ + '.o_cookies_bar_text_button', + '.o_cookies_bar_text_policy', + '.o_cookies_bar_text_title', + '.o_cookies_bar_text_primary', + '.o_cookies_bar_text_secondary', + ]; + + if (this.$savedSelectors === undefined) { + this.$savedSelectors = []; + } + + for (const selector of selectorsToKeep) { + const $currentLayoutEls = $content.find(selector).contents(); + const $newLayoutEl = $template.find(selector); + if ($currentLayoutEls.length) { + // save value before change, eg 'title' is not inside 'discrete' template + // but we want to preserve it in case of select another layout later + this.$savedSelectors[selector] = $currentLayoutEls; + } + const $savedSelector = this.$savedSelectors[selector]; + if ($newLayoutEl.length && $savedSelector && $savedSelector.length) { + $newLayoutEl.empty().append($savedSelector); + } + } + + $content.empty().append($template); + }, +}); + +/** + * Allows edition of 'cover_properties' in website models which have such + * fields (blogs, posts, events, ...). + */ +options.registry.CoverProperties = options.Class.extend({ + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + this.$image = this.$target.find('.o_record_cover_image'); + this.$filter = this.$target.find('.o_record_cover_filter'); + }, + /** + * @override + */ + start: function () { + this.$filterValueOpts = this.$el.find('[data-filter-value]'); + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles a background change. + * + * @see this.selectClass for parameters + */ + background: async function (previewMode, widgetValue, params) { + if (widgetValue === '') { + this.$image.css('background-image', ''); + this.$target.removeClass('o_record_has_cover'); + } else { + this.$image.css('background-image', `url('${widgetValue}')`); + this.$target.addClass('o_record_has_cover'); + const $defaultSizeBtn = this.$el.find('.o_record_cover_opt_size_default'); + $defaultSizeBtn.click(); + $defaultSizeBtn.closest('we-select').click(); + } + }, + /** + * @see this.selectClass for parameters + */ + filterValue: function (previewMode, widgetValue, params) { + this.$filter.css('opacity', widgetValue || 0); + this.$filter.toggleClass('oe_black', parseFloat(widgetValue) !== 0); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @private + */ + updateUI: async function () { + await this._super(...arguments); + + // TODO: `o_record_has_cover` should be handled using model field, not + // resize_class to avoid all of this. + let coverClass = this.$el.find('[data-cover-opt-name="size"] we-button.active').data('selectClass') || ''; + const bg = this.$image.css('background-image'); + if (bg && bg !== 'none') { + coverClass += " o_record_has_cover"; + } + // Update saving dataset + this.$target[0].dataset.coverClass = coverClass; + this.$target[0].dataset.textAlignClass = this.$el.find('[data-cover-opt-name="text_align"] we-button.active').data('selectClass') || ''; + this.$target[0].dataset.filterValue = this.$filterValueOpts.filter('.active').data('filterValue') || 0.0; + let colorPickerWidget = null; + this.trigger_up('user_value_widget_request', { + name: 'bg_color_opt', + onSuccess: _widget => colorPickerWidget = _widget, + }); + const color = colorPickerWidget._value; + const isCSSColor = ColorpickerWidget.isCSSColor(color); + this.$target[0].dataset.bgColorClass = isCSSColor ? '' : weUtils.computeColorClasses([color])[0]; + this.$target[0].dataset.bgColorStyle = isCSSColor ? `background-color: ${color};` : ''; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'filterValue': { + return parseFloat(this.$filter.css('opacity')).toFixed(1); + } + case 'background': { + const background = this.$image.css('background-image'); + if (background && background !== 'none') { + return background.match(/^url\(["']?(.+?)["']?\)$/)[1]; + } + return ''; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility: function (widgetName, params) { + if (params.coverOptName) { + return this.$target.data(`use_${params.coverOptName}`) === 'True'; + } + return this._super(...arguments); + }, +}); + +options.registry.ContainerWidth = options.Class.extend({ + /** + * @override + */ + cleanForSave: function () { + this.$target.removeClass('o_container_preview'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + selectClass: async function (previewMode, widgetValue, params) { + await this._super(...arguments); + if (previewMode === 'reset') { + this.$target.removeClass('o_container_preview'); + } else if (previewMode) { + this.$target.addClass('o_container_preview'); + } + }, +}); + +/** + * Allows snippets to be moved before the preceding element or after the following. + */ +options.registry.SnippetMove = options.Class.extend({ + /** + * @override + */ + start: function () { + var $buttons = this.$el.find('we-button'); + var $overlayArea = this.$overlay.find('.o_overlay_move_options'); + $overlayArea.prepend($buttons[0]); + $overlayArea.append($buttons[1]); + + return this._super(...arguments); + }, + /** + * @override + */ + onFocus: function () { + // TODO improve this: hack to hide options section if snippet move is + // the only one. + const $allOptions = this.$el.parent(); + if ($allOptions.find('we-customizeblock-option').length <= 1) { + $allOptions.addClass('d-none'); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Moves the snippet around. + * + * @see this.selectClass for parameters + */ + moveSnippet: function (previewMode, widgetValue, params) { + const isNavItem = this.$target[0].classList.contains('nav-item'); + const $tabPane = isNavItem ? $(this.$target.find('.nav-link')[0].hash) : null; + switch (widgetValue) { + case 'prev': + this.$target.prev().before(this.$target); + if (isNavItem) { + $tabPane.prev().before($tabPane); + } + break; + case 'next': + this.$target.next().after(this.$target); + if (isNavItem) { + $tabPane.next().after($tabPane); + } + break; + } + if (params.name === 'move_up_opt' || params.name === 'move_down_opt') { + dom.scrollTo(this.$target[0], { + extraOffset: 50, + easing: 'linear', + duration: 550, + }); + } + }, +}); + +options.registry.ScrollButton = options.Class.extend({ + /** + * @override + */ + start: async function () { + await this._super(...arguments); + this.$button = this.$('.o_scroll_button'); + }, + /** + * Removes button if the option is not displayed (for example in "fit + * content" height). + * + * @override + */ + updateUIVisibility: async function () { + await this._super(...arguments); + if (this.$button.length && this.el.offsetParent === null) { + this.$button.detach(); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Toggles the scroll down button. + */ + toggleButton: function (previewMode, widgetValue, params) { + if (widgetValue) { + if (!this.$button.length) { + const anchor = document.createElement('a'); + anchor.classList.add( + 'o_scroll_button', + 'mb-3', + 'rounded-circle', + 'align-items-center', + 'justify-content-center', + 'mx-auto', + 'bg-primary', + ); + anchor.href = '#'; + anchor.contentEditable = "false"; + anchor.title = _t("Scroll down to next section"); + const arrow = document.createElement('i'); + arrow.classList.add('fa', 'fa-angle-down', 'fa-3x'); + anchor.appendChild(arrow); + this.$button = $(anchor); + } + this.$target.append(this.$button); + } else { + this.$button.detach(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'toggleButton': + return !!this.$button.parent().length; + } + return this._super(...arguments); + }, +}); + +return { + UrlPickerUserValueWidget: UrlPickerUserValueWidget, + FontFamilyPickerUserValueWidget: FontFamilyPickerUserValueWidget, +}; +}); diff --git a/addons/website/static/src/js/editor/widget_link.js b/addons/website/static/src/js/editor/widget_link.js new file mode 100644 index 00000000..de3c1d9c --- /dev/null +++ b/addons/website/static/src/js/editor/widget_link.js @@ -0,0 +1,104 @@ +odoo.define('website.editor.link', function (require) { +'use strict'; + +var weWidgets = require('wysiwyg.widgets'); +var wUtils = require('website.utils'); + +weWidgets.LinkDialog.include({ + xmlDependencies: (weWidgets.LinkDialog.prototype.xmlDependencies || []).concat( + ['/website/static/src/xml/website.editor.xml'] + ), + events: _.extend({}, weWidgets.LinkDialog.prototype.events || {}, { + 'change select[name="link_anchor"]': '_onAnchorChange', + 'input input[name="url"]': '_onURLInput', + }), + custom_events: _.extend({}, weWidgets.LinkDialog.prototype.custom_events || {}, { + website_url_chosen: '_onAutocompleteClose', + }), + LINK_DEBOUNCE: 1000, + + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + this._adaptPageAnchor = _.debounce(this._adaptPageAnchor, this.LINK_DEBOUNCE); + }, + /** + * Allows the URL input to propose existing website pages. + * + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + wUtils.autocompleteWithPages(this, this.$('input[name="url"]')); + this.opened(this._adaptPageAnchor.bind(this)); + return def; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _adaptPageAnchor: function () { + var urlInputValue = this.$('input[name="url"]').val(); + var $pageAnchor = this.$('.o_link_dialog_page_anchor'); + var isFromWebsite = urlInputValue[0] === '/'; + var $selectMenu = this.$('select[name="link_anchor"]'); + var $anchorsLoading = this.$('.o_anchors_loading'); + + if ($selectMenu.data("anchor-for") !== urlInputValue) { // avoid useless query + $anchorsLoading.removeClass('d-none'); + $pageAnchor.toggleClass('d-none', !isFromWebsite); + $selectMenu.empty(); + wUtils.loadAnchors(urlInputValue).then(function (anchors) { + _.each(anchors, function (anchor) { + $selectMenu.append($('<option>', {text: anchor})); + }); + always(); + }).guardedCatch(always); + } else { + always(); + } + + function always() { + $anchorsLoading.addClass('d-none'); + $selectMenu.prop("selectedIndex", -1); + } + $selectMenu.data("anchor-for", urlInputValue); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAutocompleteClose: function () { + this._onURLInput(); + }, + /** + * @private + */ + _onAnchorChange: function () { + var anchorValue = this.$('[name="link_anchor"]').val(); + var $urlInput = this.$('[name="url"]'); + var urlInputValue = $urlInput.val(); + if (urlInputValue.indexOf('#') > -1) { + urlInputValue = urlInputValue.substr(0, urlInputValue.indexOf('#')); + } + $urlInput.val(urlInputValue + anchorValue); + }, + /** + * @override + */ + _onURLInput: function () { + this._super.apply(this, arguments); + this._adaptPageAnchor(); + }, +}); +}); diff --git a/addons/website/static/src/js/editor/wysiwyg_multizone.js b/addons/website/static/src/js/editor/wysiwyg_multizone.js new file mode 100644 index 00000000..596d40db --- /dev/null +++ b/addons/website/static/src/js/editor/wysiwyg_multizone.js @@ -0,0 +1,286 @@ +odoo.define('web_editor.wysiwyg.multizone', function (require) { +'use strict'; + +var Wysiwyg = require('web_editor.wysiwyg'); +var snippetsEditor = require('web_editor.snippet.editor'); + +/** + * 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(_.map($toggles, toggle => { + var $toggle = $(toggle); + var $dropdown = $toggle.parent(); + var shown = $dropdown.hasClass('show'); + if (shown === show) { + return; + } + var toShow = !shown; + return new Promise(resolve => { + $dropdown.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 + * + */ +var WysiwygMultizone = Wysiwyg.extend({ + /** + * @override + */ + start: function () { + var self = this; + this.options.toolbarHandler = $('#web_editor-top-edit'); + this.options.saveElement = function ($el, context, withLang) { + var outerHTML = this._getEscapedElement($el).prop('outerHTML'); + return self._saveElement(outerHTML, self.options.recordInfo, $el[0]); + }; + + // Mega menu initialization: handle dropdown openings by hand + var $megaMenuToggles = this.$('.o_mega_menu_toggle'); + $megaMenuToggles.removeAttr('data-toggle').dropdown('dispose'); + $megaMenuToggles.on('click.wysiwyg_multizone', ev => { + 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 mega menus + toggleDropdown($megaMenuToggles.not($toggle), false).then(dispose); + + // Then toggle the clicked one + toggleDropdown($toggle) + .then(dispose) + .then($el => { + var isShown = $el.parent().hasClass('show'); + this.editor.snippetsMenu.toggleMegaMenuSnippets(isShown); + }); + }); + + // 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.innerHTML = ''; + } + } + + // TODO remove this code in master by migrating users who did not + // receive the XML change about the 'oe_structure_solo' class (the + // header original XML is now correct but we changed specs after + // release to not allow multi snippets drop zones in the header). + const $headerZones = this._getEditableArea().filter((i, el) => el.closest('header#top') !== null); + // oe_structure_multi to ease custo in stable + const selector = '.oe_structure[id*="oe_structure"]:not(.oe_structure_multi)'; + $headerZones.find(selector).addBack(selector).addClass('oe_structure_solo'); + + return this._super.apply(this, arguments).then(() => { + // Showing Mega Menu snippets if one dropdown is already opened + if (this.$('.o_mega_menu').hasClass('show')) { + this.editor.snippetsMenu.toggleMegaMenuSnippets(true); + } + }); + }, + /** + * @override + * @returns {Promise} + */ + save: function () { + if (this.isDirty()) { + return this._restoreMegaMenus() + .then(() => this.editor.save(false)) + .then(() => ({isDirty: true})); + } else { + return {isDirty: false}; + } + }, + /** + * @override + */ + destroy: function () { + this._restoreMegaMenus(); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _getEditableArea: function () { + return $(':o_editable'); + }, + /** + * @private + * @param {HTMLElement} editable + */ + _saveCoverProperties: function (editable) { + var el = editable.closest('.o_record_cover_container'); + 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'); + } + + 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)} + ], + }); + }, + /** + * Saves one (dirty) element of the page. + * + * @private + * @param {jQuery} $el - the element to save + * @param {Object} context - the context to use for the saving rpc + * @param {boolean} [withLang=false] + * false if the lang must be omitted in the context (saving "master" + * page element) + */ + _saveElement: function (outerHTML, recordInfo, editable) { + var promises = []; + + var $el = $(editable); + + // Saving a view content + var viewID = $el.data('oe-id'); + if (viewID) { + promises.push(this._rpc({ + model: 'ir.ui.view', + method: 'save', + args: [ + viewID, + outerHTML, + $el.data('oe-xpath') || null, + ], + context: recordInfo.context, + })); + } + + // 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 = _.without($el.attr('class').split(' '), 'dropdown-menu', 'o_mega_menu', 'show'); + 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(editable); + 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_multizone') + .attr('data-toggle', 'dropdown') + .dropdown({}); + return toggleDropdown($megaMenuToggles, false); + }, +}); + +snippetsEditor.Class.include({ + /** + * @private + * @param {boolean} show + */ + toggleMegaMenuSnippets: function (show) { + setTimeout(() => this._activateSnippet(false)); + this._showMegaMenuSnippets = show; + this._filterSnippets(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _filterSnippets(search) { + this._super(...arguments); + if (!this._showMegaMenuSnippets) { + this.el.querySelector('#snippet_mega_menu').classList.add('d-none'); + } + }, + /** + * @override + */ + _insertDropzone: function ($hook) { + var $hookParent = $hook.parent(); + var $dropzone = this._super(...arguments); + $dropzone.attr('data-editor-message', $hookParent.attr('data-editor-message')); + $dropzone.attr('data-editor-sub-message', $hookParent.attr('data-editor-sub-message')); + return $dropzone; + }, +}); + +return WysiwygMultizone; +}); diff --git a/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js b/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js new file mode 100644 index 00000000..978ac4d9 --- /dev/null +++ b/addons/website/static/src/js/editor/wysiwyg_multizone_translate.js @@ -0,0 +1,301 @@ +odoo.define('web_editor.wysiwyg.multizone.translate', function (require) { +'use strict'; + +var core = require('web.core'); +var webDialog = require('web.Dialog'); +var WysiwygMultizone = require('web_editor.wysiwyg.multizone'); +var rte = require('web_editor.rte'); +var Dialog = require('wysiwyg.widgets.Dialog'); +var websiteNavbarData = require('website.navbar'); + +var _t = core._t; + + +var RTETranslatorWidget = rte.Class.extend({ + /** + * If the element holds a translation, saves it. Otherwise, fallback to the + * standard saving but with the lang kept. + * + * @override + */ + _saveElement: function ($el, context, withLang) { + var self = this; + if ($el.data('oe-translation-id')) { + return this._rpc({ + model: 'ir.translation', + method: 'save_html', + args: [ + [+$el.data('oe-translation-id')], + this._getEscapedElement($el).html() + ], + context: context, + }); + } + return this._super($el, context, withLang === undefined ? true : withLang); + }, +}); + +var AttributeTranslateDialog = Dialog.extend({ + /** + * @constructor + */ + init: function (parent, options, node) { + this._super(parent, _.extend({ + title: _t("Translate Attribute"), + buttons: [ + {text: _t("Close"), classes: 'btn-primary', click: this.save} + ], + }, options || {})); + this.translation = $(node).data('translation'); + }, + /** + * @override + */ + start: function () { + var $group = $('<div/>', {class: 'form-group'}).appendTo(this.$el); + _.each(this.translation, function (node, attr) { + var $node = $(node); + var $label = $('<label class="col-form-label"></label>').text(attr); + var $input = $('<input class="form-control"/>').val($node.html()); + $input.on('change keyup', function () { + var value = $input.val(); + $node.html(value).trigger('change', node); + $node.data('$node').attr($node.data('attribute'), value).trigger('translate'); + $node.trigger('change'); + }); + $group.append($label).append($input); + }); + return this._super.apply(this, arguments); + } +}); + +var WysiwygTranslate = WysiwygMultizone.extend({ + custom_events: _.extend({}, WysiwygMultizone.prototype.custom_events || {}, { + ready_to_save: '_onSave', + rte_change: '_onChange', + }), + + /** + * @override + * @param {string} options.lang + */ + init: function (parent, options) { + this.lang = options.lang; + options.recordInfo = _.defaults({ + context: {lang: this.lang} + }, options.recordInfo, options); + this._super.apply(this, arguments); + }, + /** + * @override + */ + start: function () { + var self = this; + // Hacky way to keep the top editor toolbar in translate mode for now + this.$webEditorTopEdit = $('<div id="web_editor-top-edit"></div>').prependTo(document.body); + this.options.toolbarHandler = this.$webEditorTopEdit; + this.editor = new (this.Editor)(this, Object.assign({Editor: RTETranslatorWidget}, this.options)); + this.$editor = this.editor.rte.editable(); + var promise = this.editor.prependTo(this.$editor[0].ownerDocument.body); + + return promise.then(function () { + self._relocateEditorBar(); + var attrs = ['placeholder', 'title', 'alt']; + _.each(attrs, function (attr) { + self._getEditableArea().filter('[' + attr + '*="data-oe-translation-id="]').filter(':empty, input, select, textarea, img').each(function () { + var $node = $(this); + var translation = $node.data('translation') || {}; + var trans = $node.attr(attr); + var match = trans.match(/<span [^>]*data-oe-translation-id="([0-9]+)"[^>]*>(.*)<\/span>/); + var $trans = $(trans).addClass('d-none o_editable o_editable_translatable_attribute').appendTo('body'); + $trans.data('$node', $node).data('attribute', attr); + + translation[attr] = $trans[0]; + $node.attr(attr, match[2]); + + var select2 = $node.data('select2'); + if (select2) { + select2.blur(); + $node.on('translate', function () { + select2.blur(); + }); + $node = select2.container.find('input'); + } + $node.addClass('o_translatable_attribute').data('translation', translation); + }); + }); + + self.translations = []; + self.$editables_attr = self._getEditableArea().filter('.o_translatable_attribute'); + self.$editables_attribute = $('.o_editable_translatable_attribute'); + + self.$editables_attribute.on('change', function () { + self.trigger_up('rte_change', {target: this}); + }); + + self._markTranslatableNodes(); + }); + }, + /** + * @override + */ + destroy: function () { + this._super(...arguments); + this.$webEditorTopEdit.remove(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {Boolean} + */ + isDirty: function () { + return this._super() || this.$editables_attribute.hasClass('o_dirty'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Return the editable area. + * + * @override + * @returns {JQuery} + */ + _getEditableArea: function () { + var $editables = this._super(); + return $editables.add(this.$editables_attribute); + }, + /** + * Return an object describing the linked record. + * + * @override + * @param {Object} options + * @returns {Object} {res_id, res_model, xpath} + */ + _getRecordInfo: function (options) { + options = options || {}; + var recordInfo = this._super(options); + var $editable = $(options.target).closest(this._getEditableArea()); + if (!$editable.length) { + $editable = $(this._getFocusedEditable()); + } + recordInfo.context.lang = this.lang; + recordInfo.translation_id = $editable.data('oe-translation-id')|0; + return recordInfo; + }, + /** + * @override + * @returns {Object} the summernote configuration + */ + _editorOptions: function () { + var options = this._super(); + options.toolbar = [ + // todo: hide this feature for field (data-oe-model) + ['font', ['bold', 'italic', 'underline', 'clear']], + ['fontsize', ['fontsize']], + ['color', ['color']], + // keep every time + ['history', ['undo', 'redo']], + ]; + return options; + }, + /** + * Called when text is edited -> make sure text is not messed up and mark + * the element as dirty. + * + * @override + * @param {Jquery Event} [ev] + */ + _onChange: function (ev) { + var $node = $(ev.data.target); + if (!$node.length) { + return; + } + $node.find('div,p').each(function () { // remove P,DIV elements which might have been inserted because of copy-paste + var $p = $(this); + $p.after($p.html()).remove(); + }); + var trans = this._getTranlationObject($node[0]); + $node.toggleClass('o_dirty', trans.value !== $node.html().replace(/[ \t\n\r]+/, ' ')); + }, + /** + * Returns a translation object. + * + * @private + * @param {Node} node + * @returns {Object} + */ + _getTranlationObject: function (node) { + var $node = $(node); + var id = +$node.data('oe-translation-id'); + if (!id) { + id = $node.data('oe-model')+','+$node.data('oe-id')+','+$node.data('oe-field'); + } + var trans = _.find(this.translations, function (trans) { + return trans.id === id; + }); + if (!trans) { + this.translations.push(trans = {'id': id}); + } + return trans; + }, + /** + * @private + */ + _markTranslatableNodes: function () { + var self = this; + this._getEditableArea().each(function () { + var $node = $(this); + var trans = self._getTranlationObject(this); + trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' '); + }); + this._getEditableArea().prependEvent('click.translator', function (ev) { + if (ev.ctrlKey || !$(ev.target).is(':o_editable')) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + }); + + // attributes + + this.$editables_attr.each(function () { + var $node = $(this); + var translation = $node.data('translation'); + _.each(translation, function (node, attr) { + var trans = self._getTranlationObject(node); + trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' '); + $node.attr('data-oe-translation-state', (trans.state || 'to_translate')); + }); + }); + + this.$editables_attr.prependEvent('mousedown.translator click.translator mouseup.translator', function (ev) { + if (ev.ctrlKey) { + return; + } + ev.preventDefault(); + ev.stopPropagation(); + if (ev.type !== 'mousedown') { + return; + } + + new AttributeTranslateDialog(self, {}, ev.target).open(); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + _onSave: function (ev) { + ev.stopPropagation(); + }, +}); + +return WysiwygTranslate; +}); |
