summaryrefslogtreecommitdiff
path: root/addons/website/static/src
diff options
context:
space:
mode:
Diffstat (limited to 'addons/website/static/src')
-rw-r--r--addons/website/static/src/js/content/snippets.animation.js1467
-rw-r--r--addons/website/static/src/js/editor/snippets.options.js1888
-rw-r--r--addons/website/static/src/scss/website.scss921
-rw-r--r--addons/website/static/src/snippets/s_image_gallery/000.js172
-rw-r--r--addons/website/static/src/snippets/s_image_gallery/000.scss156
-rw-r--r--addons/website/static/src/snippets/s_image_gallery/options.js456
6 files changed, 5060 insertions, 0 deletions
diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js
new file mode 100644
index 00000000000..8dcd9809c31
--- /dev/null
+++ b/addons/website/static/src/js/content/snippets.animation.js
@@ -0,0 +1,1467 @@
+odoo.define('website.content.snippets.animation', function (require) {
+'use strict';
+
+/**
+ * Provides a way to start JS code for snippets' initialization and animations.
+ */
+
+var Class = require('web.Class');
+const ColorpickerDialog = require('web.ColorpickerDialog');
+var config = require('web.config');
+var core = require('web.core');
+var mixins = require('web.mixins');
+var publicWidget = require('web.public.widget');
+var utils = require('web.utils');
+
+var qweb = core.qweb;
+const _t = core._t;
+
+// Initialize fallbacks for the use of requestAnimationFrame,
+// cancelAnimationFrame and performance.now()
+window.requestAnimationFrame = window.requestAnimationFrame
+ || window.webkitRequestAnimationFrame
+ || window.mozRequestAnimationFrame
+ || window.msRequestAnimationFrame
+ || window.oRequestAnimationFrame;
+window.cancelAnimationFrame = window.cancelAnimationFrame
+ || window.webkitCancelAnimationFrame
+ || window.mozCancelAnimationFrame
+ || window.msCancelAnimationFrame
+ || window.oCancelAnimationFrame;
+if (!window.performance || !window.performance.now) {
+ window.performance = {
+ now: function () {
+ return Date.now();
+ }
+ };
+}
+
+/**
+ * Add the notion of edit mode to public widgets.
+ */
+publicWidget.Widget.include({
+ /**
+ * Indicates if the widget should not be instantiated in edit. The default
+ * is true, indeed most (all?) defined widgets only want to initialize
+ * events and states which should not be active in edit mode (this is
+ * especially true for non-website widgets).
+ *
+ * @type {boolean}
+ */
+ disabledInEditableMode: true,
+ /**
+ * Acts as @see Widget.events except that the events are only binded if the
+ * Widget instance is instanciated in edit mode. The property is not
+ * considered if @see disabledInEditableMode is false.
+ */
+ edit_events: null,
+ /**
+ * Acts as @see Widget.events except that the events are only binded if the
+ * Widget instance is instanciated in readonly mode. The property only
+ * makes sense if @see disabledInEditableMode is false, you should simply
+ * use @see Widget.events otherwise.
+ */
+ read_events: null,
+
+ /**
+ * Initializes the events that will need to be binded according to the
+ * given mode.
+ *
+ * @constructor
+ * @param {Object} parent
+ * @param {Object} [options]
+ * @param {boolean} [options.editableMode=false]
+ * true if the page is in edition mode
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ this.editableMode = this.options.editableMode || false;
+ var extraEvents = this.editableMode ? this.edit_events : this.read_events;
+ if (extraEvents) {
+ this.events = _.extend({}, this.events || {}, extraEvents);
+ }
+ },
+});
+
+/**
+ * In charge of handling one animation loop using the requestAnimationFrame
+ * feature. This is used by the `Animation` class below and should not be called
+ * directly by an end developer.
+ *
+ * This uses a simple API: it can be started, stopped, played and paused.
+ */
+var AnimationEffect = Class.extend(mixins.ParentedMixin, {
+ /**
+ * @constructor
+ * @param {Object} parent
+ * @param {function} updateCallback - the animation update callback
+ * @param {string} [startEvents=scroll]
+ * space separated list of events which starts the animation loop
+ * @param {jQuery|DOMElement} [$startTarget=window]
+ * the element(s) on which the startEvents are listened
+ * @param {Object} [options]
+ * @param {function} [options.getStateCallback]
+ * a function which returns a value which represents the state of the
+ * animation, i.e. for two same value, no refreshing of the animation
+ * is needed. Can be used for optimization. If the $startTarget is
+ * the window element, this defaults to returning the current
+ * scoll offset of the window or the size of the window for the
+ * scroll and resize events respectively.
+ * @param {string} [options.endEvents]
+ * space separated list of events which pause the animation loop. If
+ * not given, the animation is stopped after a while (if no
+ * startEvents is received again)
+ * @param {jQuery|DOMElement} [options.$endTarget=$startTarget]
+ * the element(s) on which the endEvents are listened
+ */
+ init: function (parent, updateCallback, startEvents, $startTarget, options) {
+ mixins.ParentedMixin.init.call(this);
+ this.setParent(parent);
+
+ options = options || {};
+ this._minFrameTime = 1000 / (options.maxFPS || 100);
+
+ // Initialize the animation startEvents, startTarget, endEvents, endTarget and callbacks
+ this._updateCallback = updateCallback;
+ this.startEvents = startEvents || 'scroll';
+ this.$startTarget = $($startTarget || window);
+ if (options.getStateCallback) {
+ this._getStateCallback = options.getStateCallback;
+ } else if (this.startEvents === 'scroll' && this.$startTarget[0] === window) {
+ this._getStateCallback = function () {
+ return window.pageYOffset;
+ };
+ } else if (this.startEvents === 'resize' && this.$startTarget[0] === window) {
+ this._getStateCallback = function () {
+ return {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ };
+ };
+ } else {
+ this._getStateCallback = function () {
+ return undefined;
+ };
+ }
+ this.endEvents = options.endEvents || false;
+ this.$endTarget = options.$endTarget ? $(options.$endTarget) : this.$startTarget;
+
+ this._updateCallback = this._updateCallback.bind(parent);
+ this._getStateCallback = this._getStateCallback.bind(parent);
+
+ // Add a namespace to events using the generated uid
+ this._uid = '_animationEffect' + _.uniqueId();
+ this.startEvents = _processEvents(this.startEvents, this._uid);
+ if (this.endEvents) {
+ this.endEvents = _processEvents(this.endEvents, this._uid);
+ }
+
+ function _processEvents(events, namespace) {
+ events = events.split(' ');
+ return _.each(events, function (e, index) {
+ events[index] += ('.' + namespace);
+ }).join(' ');
+ }
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ mixins.ParentedMixin.destroy.call(this);
+ this.stop();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Initializes when the animation must be played and paused and initializes
+ * the animation first frame.
+ */
+ start: function () {
+ // Initialize the animation first frame
+ this._paused = false;
+ this._rafID = window.requestAnimationFrame((function (t) {
+ this._update(t);
+ this._paused = true;
+ }).bind(this));
+
+ // Initialize the animation play/pause events
+ if (this.endEvents) {
+ /**
+ * If there are endEvents, the animation should begin playing when
+ * the startEvents are triggered on the $startTarget and pause when
+ * the endEvents are triggered on the $endTarget.
+ */
+ this.$startTarget.on(this.startEvents, (function (e) {
+ if (this._paused) {
+ _.defer(this.play.bind(this, e));
+ }
+ }).bind(this));
+ this.$endTarget.on(this.endEvents, (function () {
+ if (!this._paused) {
+ _.defer(this.pause.bind(this));
+ }
+ }).bind(this));
+ } else {
+ /**
+ * Else, if there is no endEvents, the animation should begin playing
+ * when the startEvents are *continuously* triggered on the
+ * $startTarget or fully played once. To achieve this, the animation
+ * begins playing and is scheduled to pause after 2 seconds. If the
+ * startEvents are triggered during that time, this is not paused
+ * for another 2 seconds. This allows to describe an "effect"
+ * animation (which lasts less than 2 seconds) or an animation which
+ * must be playing *during* an event (scroll, mousemove, resize,
+ * repeated clicks, ...).
+ */
+ var pauseTimer = null;
+ this.$startTarget.on(this.startEvents, _.throttle((function (e) {
+ this.play(e);
+
+ clearTimeout(pauseTimer);
+ pauseTimer = _.delay((function () {
+ this.pause();
+ pauseTimer = null;
+ }).bind(this), 2000);
+ }).bind(this), 250, {trailing: false}));
+ }
+ },
+ /**
+ * Pauses the animation and destroys the attached events which trigger the
+ * animation to be played or paused.
+ */
+ stop: function () {
+ this.$startTarget.off(this.startEvents);
+ if (this.endEvents) {
+ this.$endTarget.off(this.endEvents);
+ }
+ this.pause();
+ },
+ /**
+ * Forces the requestAnimationFrame loop to start.
+ *
+ * @param {Event} e - the event which triggered the animation to play
+ */
+ play: function (e) {
+ this._newEvent = e;
+ if (!this._paused) {
+ return;
+ }
+ this._paused = false;
+ this._rafID = window.requestAnimationFrame(this._update.bind(this));
+ this._lastUpdateTimestamp = undefined;
+ },
+ /**
+ * Forces the requestAnimationFrame loop to stop.
+ */
+ pause: function () {
+ if (this._paused) {
+ return;
+ }
+ this._paused = true;
+ window.cancelAnimationFrame(this._rafID);
+ this._lastUpdateTimestamp = undefined;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Callback which is repeatedly called by the requestAnimationFrame loop.
+ * It controls the max fps at which the animation is running and initializes
+ * the values that the update callback needs to describe the animation
+ * (state, elapsedTime, triggered event).
+ *
+ * @private
+ * @param {DOMHighResTimeStamp} timestamp
+ */
+ _update: function (timestamp) {
+ if (this._paused) {
+ return;
+ }
+ this._rafID = window.requestAnimationFrame(this._update.bind(this));
+
+ // Check the elapsed time since the last update callback call.
+ // Consider it 0 if there is no info of last timestamp and leave this
+ // _update call if it was called too soon (would overflow the set max FPS).
+ var elapsedTime = 0;
+ if (this._lastUpdateTimestamp) {
+ elapsedTime = timestamp - this._lastUpdateTimestamp;
+ if (elapsedTime < this._minFrameTime) {
+ return;
+ }
+ }
+
+ // Check the new animation state thanks to the get state callback and
+ // store its new value. If the state is the same as the previous one,
+ // leave this _update call, except if there is an event which triggered
+ // the "play" method again.
+ var animationState = this._getStateCallback(elapsedTime, this._newEvent);
+ if (!this._newEvent
+ && animationState !== undefined
+ && _.isEqual(animationState, this._animationLastState)) {
+ return;
+ }
+ this._animationLastState = animationState;
+
+ // Call the update callback with frame parameters
+ this._updateCallback(this._animationLastState, elapsedTime, this._newEvent);
+ this._lastUpdateTimestamp = timestamp; // Save the timestamp at which the update callback was really called
+ this._newEvent = undefined; // Forget the event which triggered the last "play" call
+ },
+});
+
+/**
+ * Also register AnimationEffect automatically (@see effects, _prepareEffects).
+ */
+var Animation = publicWidget.Widget.extend({
+ /**
+ * The max FPS at which all the automatic animation effects will be
+ * running by default.
+ */
+ maxFPS: 100,
+ /**
+ * @see this._prepareEffects
+ *
+ * @type {Object[]}
+ * @type {string} startEvents
+ * The names of the events which trigger the effect to begin playing.
+ * @type {string} [startTarget]
+ * A selector to find the target where to listen for the start events
+ * (if no selector, the window target will be used). If the whole
+ * $target of the animation should be used, use the 'selector' string.
+ * @type {string} [endEvents]
+ * The name of the events which trigger the end of the effect (if none
+ * is defined, the animation will stop after a while
+ * @see AnimationEffect.start).
+ * @type {string} [endTarget]
+ * A selector to find the target where to listen for the end events
+ * (if no selector, the startTarget will be used). If the whole
+ * $target of the animation should be used, use the 'selector' string.
+ * @type {string} update
+ * A string which refers to a method which will be used as the update
+ * callback for the effect. It receives 3 arguments: the animation
+ * state, the elapsedTime since last update and the event which
+ * triggered the animation (undefined if just a new update call
+ * without trigger).
+ * @type {string} [getState]
+ * The animation state is undefined by default, the scroll offset for
+ * the particular {startEvents: 'scroll'} effect and an object with
+ * width and height for the particular {startEvents: 'resize'} effect.
+ * There is the possibility to define the getState callback of the
+ * animation effect with this key. This allows to improve performance
+ * even further in some cases.
+ */
+ effects: [],
+
+ /**
+ * Initializes the animation. The method should not be called directly as
+ * called automatically on animation instantiation and on restart.
+ *
+ * Also, prepares animation's effects and start them if any.
+ *
+ * @override
+ */
+ start: function () {
+ this._prepareEffects();
+ _.each(this._animationEffects, function (effect) {
+ effect.start();
+ });
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Registers `AnimationEffect` instances.
+ *
+ * This can be done by extending this method and calling the @see _addEffect
+ * method in it or, better, by filling the @see effects property.
+ *
+ * @private
+ */
+ _prepareEffects: function () {
+ this._animationEffects = [];
+
+ var self = this;
+ _.each(this.effects, function (desc) {
+ self._addEffect(self[desc.update], desc.startEvents, _findTarget(desc.startTarget), {
+ getStateCallback: desc.getState && self[desc.getState],
+ endEvents: desc.endEvents || undefined,
+ $endTarget: _findTarget(desc.endTarget),
+ maxFPS: self.maxFPS,
+ });
+
+ // Return the DOM element matching the selector in the form
+ // described above.
+ function _findTarget(selector) {
+ if (selector) {
+ if (selector === 'selector') {
+ return self.$target;
+ }
+ return self.$(selector);
+ }
+ return undefined;
+ }
+ });
+ },
+ /**
+ * Registers a new `AnimationEffect` according to given parameters.
+ *
+ * @private
+ * @see AnimationEffect.init
+ */
+ _addEffect: function (updateCallback, startEvents, $startTarget, options) {
+ this._animationEffects.push(
+ new AnimationEffect(this, updateCallback, startEvents, $startTarget, options)
+ );
+ },
+});
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+var registry = publicWidget.registry;
+
+registry.slider = publicWidget.Widget.extend({
+ selector: '.carousel',
+ disabledInEditableMode: false,
+ edit_events: {
+ 'content_changed': '_onContentChanged',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$('img').on('load.slider', () => this._computeHeights());
+ this._computeHeights();
+ // Initialize carousel and pause if in edit mode.
+ this.$target.carousel(this.editableMode ? 'pause' : undefined);
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ this.$('img').off('.slider');
+ this.$target.carousel('pause');
+ this.$target.removeData('bs.carousel');
+ _.each(this.$('.carousel-item'), function (el) {
+ $(el).css('min-height', '');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _computeHeights: function () {
+ var maxHeight = 0;
+ var $items = this.$('.carousel-item');
+ $items.css('min-height', '');
+ _.each($items, function (el) {
+ var $item = $(el);
+ var isActive = $item.hasClass('active');
+ $item.addClass('active');
+ var height = $item.outerHeight();
+ if (height > maxHeight) {
+ maxHeight = height;
+ }
+ $item.toggleClass('active', isActive);
+ });
+ $items.css('min-height', maxHeight);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onContentChanged: function (ev) {
+ this._computeHeights();
+ },
+});
+
+registry.parallax = Animation.extend({
+ selector: '.parallax',
+ disabledInEditableMode: false,
+ effects: [{
+ startEvents: 'scroll',
+ update: '_onWindowScroll',
+ }],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._rebuild();
+ $(window).on('resize.animation_parallax', _.debounce(this._rebuild.bind(this), 500));
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ $(window).off('.animation_parallax');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prepares the background element which will scroll at a different speed
+ * according to the viewport dimensions and other snippet parameters.
+ *
+ * @private
+ */
+ _rebuild: function () {
+ // Add/find bg DOM element to hold the parallax bg (support old v10.0 parallax)
+ if (!this.$bg || !this.$bg.length) {
+ this.$bg = this.$('> .s_parallax_bg');
+ if (!this.$bg.length) {
+ this.$bg = $('<span/>', {
+ class: 's_parallax_bg' + (this.$target.hasClass('oe_custom_bg') ? ' oe_custom_bg' : ''),
+ }).prependTo(this.$target);
+ }
+ }
+ var urlTarget = this.$target.css('background-image');
+ if (urlTarget !== 'none') {
+ this.$bg.css('background-image', urlTarget);
+ }
+ this.$target.css('background-image', 'none');
+
+ // Get parallax speed
+ this.speed = parseFloat(this.$target.attr('data-scroll-background-ratio') || 0);
+
+ // Reset offset if parallax effect will not be performed and leave
+ this.$target.toggleClass('s_parallax_is_fixed', this.speed === 1);
+ var noParallaxSpeed = (this.speed === 0 || this.speed === 1);
+ this.$target.toggleClass('s_parallax_no_overflow_hidden', noParallaxSpeed);
+ if (noParallaxSpeed) {
+ this.$bg.css({
+ transform: '',
+ top: '',
+ bottom: '',
+ });
+ return;
+ }
+
+ // Initialize parallax data according to snippet and viewport dimensions
+ this.viewport = document.body.clientHeight - $('#wrapwrap').position().top;
+ this.visibleArea = [this.$target.offset().top];
+ this.visibleArea.push(this.visibleArea[0] + this.$target.innerHeight() + this.viewport);
+ this.ratio = this.speed * (this.viewport / 10);
+
+ // Provide a "safe-area" to limit parallax
+ this.$bg.css({
+ top: -this.ratio,
+ bottom: -this.ratio,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Effects
+ //--------------------------------------------------------------------------
+
+ /**
+ * Describes how to update the snippet when the window scrolls.
+ *
+ * @private
+ * @param {integer} scrollOffset
+ */
+ _onWindowScroll: function (scrollOffset) {
+ // Speed == 0 is no effect and speed == 1 is handled by CSS only
+ if (this.speed === 0 || this.speed === 1) {
+ return;
+ }
+
+ // Perform translation if the element is visible only
+ var vpEndOffset = scrollOffset + this.viewport;
+ if (vpEndOffset >= this.visibleArea[0]
+ && vpEndOffset <= this.visibleArea[1]) {
+ this.$bg.css('transform', 'translateY(' + _getNormalizedPosition.call(this, vpEndOffset) + 'px)');
+ }
+
+ function _getNormalizedPosition(pos) {
+ // Normalize scroll in a 1 to 0 range
+ var r = (pos - this.visibleArea[1]) / (this.visibleArea[0] - this.visibleArea[1]);
+ // Normalize accordingly to current options
+ return Math.round(this.ratio * (2 * r - 1));
+ }
+ },
+});
+
+registry.mediaVideo = publicWidget.Widget.extend({
+ selector: '.media_iframe_video',
+
+ /**
+ * @override
+ */
+ start: function () {
+ // TODO: this code should be refactored to make more sense and be better
+ // integrated with Odoo (this refactoring should be done in master).
+
+ var def = this._super.apply(this, arguments);
+ if (this.$target.children('iframe').length) {
+ // There already is an <iframe/>, do nothing
+ return def;
+ }
+
+ // Bug fix / compatibility: empty the <div/> element as all information
+ // to rebuild the iframe should have been saved on the <div/> element
+ this.$target.empty();
+
+ // Add extra content for size / edition
+ this.$target.append(
+ '<div class="css_editable_mode_display">&nbsp;</div>' +
+ '<div class="media_iframe_video_size">&nbsp;</div>'
+ );
+
+ // Rebuild the iframe. Depending on version / compatibility / instance,
+ // the src is saved in the 'data-src' attribute or the
+ // 'data-oe-expression' one (the latter is used as a workaround in 10.0
+ // system but should obviously be reviewed in master).
+ this.$target.append($('<iframe/>', {
+ src: _.escape(this.$target.data('oe-expression') || this.$target.data('src')),
+ frameborder: '0',
+ allowfullscreen: 'allowfullscreen',
+ sandbox: 'allow-scripts allow-same-origin', // https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/
+ }));
+
+ return def;
+ },
+});
+
+registry.backgroundVideo = publicWidget.Widget.extend({
+ selector: '.o_background_video',
+ xmlDependencies: ['/website/static/src/xml/website.background.video.xml'],
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start: function () {
+ var proms = [this._super(...arguments)];
+
+ this.videoSrc = this.el.dataset.bgVideoSrc;
+ this.iframeID = _.uniqueId('o_bg_video_iframe_');
+
+ this.isYoutubeVideo = this.videoSrc.indexOf('youtube') >= 0;
+ this.isMobileEnv = config.device.size_class <= config.device.SIZES.LG && config.device.touch;
+ if (this.isYoutubeVideo && this.isMobileEnv) {
+ this.videoSrc = this.videoSrc + "&enablejsapi=1";
+
+ if (!window.YT) {
+ proms.push(new Promise(resolve => {
+ window.onYouTubeIframeAPIReady = () => resolve();
+ }));
+ $('<script/>', {
+ src: 'https://www.youtube.com/iframe_api',
+ }).appendTo('head');
+ }
+ }
+
+ var throttledUpdate = _.throttle(() => this._adjustIframe(), 50);
+
+ var $dropdownMenu = this.$el.closest('.dropdown-menu');
+ if ($dropdownMenu.length) {
+ this.$dropdownParent = $dropdownMenu.parent();
+ this.$dropdownParent.on('shown.bs.dropdown.backgroundVideo', throttledUpdate);
+ }
+
+ $(window).on('resize.' + this.iframeID, throttledUpdate);
+
+ return Promise.all(proms).then(() => this._appendBgVideo());
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+
+ if (this.$dropdownParent) {
+ this.$dropdownParent.off('.backgroundVideo');
+ }
+
+ $(window).off('resize.' + this.iframeID);
+
+ if (this.$bgVideoContainer) {
+ this.$bgVideoContainer.remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adjusts iframe sizes and position so that it fills the container and so
+ * that it is centered in it.
+ *
+ * @private
+ */
+ _adjustIframe: function () {
+ this.$iframe.removeClass('show');
+
+ // Adjust the iframe
+ var wrapperWidth = this.$target.innerWidth();
+ var wrapperHeight = this.$target.innerHeight();
+ var relativeRatio = (wrapperWidth / wrapperHeight) / (16 / 9);
+ var style = {};
+ if (relativeRatio >= 1.0) {
+ style['width'] = '100%';
+ style['height'] = (relativeRatio * 100) + '%';
+ style['left'] = '0';
+ style['top'] = (-(relativeRatio - 1.0) / 2 * 100) + '%';
+ } else {
+ style['width'] = ((1 / relativeRatio) * 100) + '%';
+ style['height'] = '100%';
+ style['left'] = (-((1 / relativeRatio) - 1.0) / 2 * 100) + '%';
+ style['top'] = '0';
+ }
+ this.$iframe.css(style);
+
+ void this.$iframe[0].offsetWidth; // Force style addition
+ this.$iframe.addClass('show');
+ },
+ /**
+ * Append background video related elements to the target.
+ *
+ * @private
+ */
+ _appendBgVideo: function () {
+ var $oldContainer = this.$bgVideoContainer || this.$('> .o_bg_video_container');
+ this.$bgVideoContainer = $(qweb.render('website.background.video', {
+ videoSrc: this.videoSrc,
+ iframeID: this.iframeID,
+ }));
+ this.$iframe = this.$bgVideoContainer.find('.o_bg_video_iframe');
+ this.$iframe.one('load', () => {
+ this.$bgVideoContainer.find('.o_bg_video_loading').remove();
+ });
+ this.$bgVideoContainer.prependTo(this.$target);
+ $oldContainer.remove();
+
+ this._adjustIframe();
+
+ // YouTube does not allow to auto-play video in mobile devices, so we
+ // have to play the video manually.
+ if (this.isMobileEnv && this.isYoutubeVideo) {
+ new window.YT.Player(this.iframeID, {
+ events: {
+ onReady: ev => ev.target.playVideo(),
+ }
+ });
+ }
+ },
+});
+
+registry.ul = publicWidget.Widget.extend({
+ selector: 'ul.o_ul_folded, ol.o_ul_folded',
+ events: {
+ 'click .o_ul_toggle_next': '_onToggleNextClick',
+ 'click .o_ul_toggle_self': '_onToggleSelfClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a "toggle next" ul is clicked.
+ *
+ * @private
+ */
+ _onToggleNextClick: function (ev) {
+ ev.preventDefault();
+ var $target = $(ev.currentTarget);
+ $target.toggleClass('o_open');
+ $target.closest('li').next().toggleClass('o_close');
+ },
+ /**
+ * Called when a "toggle self" ul is clicked.
+ *
+ * @private
+ */
+ _onToggleSelfClick: function (ev) {
+ ev.preventDefault();
+ var $target = $(ev.currentTarget);
+ $target.toggleClass('o_open');
+ $target.closest('li').find('ul,ol').toggleClass('o_close');
+ },
+});
+
+registry.socialShare = publicWidget.Widget.extend({
+ selector: '.oe_social_share',
+ xmlDependencies: ['/website/static/src/xml/website.share.xml'],
+ events: {
+ 'mouseenter': '_onMouseEnter',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _bindSocialEvent: function () {
+ this.$('.oe_social_facebook').click($.proxy(this._renderSocial, this, 'facebook'));
+ this.$('.oe_social_twitter').click($.proxy(this._renderSocial, this, 'twitter'));
+ this.$('.oe_social_linkedin').click($.proxy(this._renderSocial, this, 'linkedin'));
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ this.$el.popover({
+ content: qweb.render('website.social_hover', {medias: this.socialList}),
+ placement: 'bottom',
+ container: this.$el,
+ html: true,
+ trigger: 'manual',
+ animation: false,
+ }).popover("show");
+
+ this.$el.off('mouseleave.socialShare').on('mouseleave.socialShare', function () {
+ var self = this;
+ setTimeout(function () {
+ if (!$(".popover:hover").length) {
+ $(self).popover('dispose');
+ }
+ }, 200);
+ });
+ },
+ /**
+ * @private
+ */
+ _renderSocial: function (social) {
+ var url = this.$el.data('urlshare') || document.URL.split(/[?#]/)[0];
+ url = encodeURIComponent(url);
+ var title = document.title.split(" | ")[0]; // get the page title without the company name
+ var hashtags = ' #' + document.title.split(" | ")[1].replace(' ', '') + ' ' + this.hashtags; // company name without spaces (for hashtag)
+ var socialNetworks = {
+ 'facebook': 'https://www.facebook.com/sharer/sharer.php?u=' + url,
+ 'twitter': 'https://twitter.com/intent/tweet?original_referer=' + url + '&text=' + encodeURIComponent(title + hashtags + ' - ') + url,
+ 'linkedin': 'https://www.linkedin.com/shareArticle?mini=true&url=' + url + '&title=' + encodeURIComponent(title),
+ };
+ if (!_.contains(_.keys(socialNetworks), social)) {
+ return;
+ }
+ var wHeight = 500;
+ var wWidth = 500;
+ window.open(socialNetworks[social], '', 'menubar=no, toolbar=no, resizable=yes, scrollbar=yes, height=' + wHeight + ',width=' + wWidth);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the user hovers the animation element -> open the social
+ * links popover.
+ *
+ * @private
+ */
+ _onMouseEnter: function () {
+ var social = this.$el.data('social');
+ this.socialList = social ? social.split(',') : ['facebook', 'twitter', 'linkedin'];
+ this.hashtags = this.$el.data('hashtags') || '';
+
+ this._render();
+ this._bindSocialEvent();
+ },
+});
+
+registry.anchorSlide = publicWidget.Widget.extend({
+ selector: 'a[href^="/"][href*="#"], a[href^="#"]',
+ events: {
+ 'click': '_onAnimateClick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAnimateClick: function (ev) {
+ if (this.$target[0].pathname !== window.location.pathname) {
+ return;
+ }
+ var hash = this.$target[0].hash;
+ if (!utils.isValidAnchor(hash)) {
+ return;
+ }
+ var $anchor = $(hash);
+ if (!$anchor.length || !$anchor.attr('data-anchor')) {
+ return;
+ }
+ ev.preventDefault();
+ $('html, body').animate({
+ scrollTop: $anchor.offset().top,
+ }, 500);
+ },
+});
+
+registry.countdown = publicWidget.Widget.extend({
+ selector: '.s_countdown',
+ xmlDependencies: ['/website/static/src/xml/website.s_countdown.xml'],
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.$wrapper = this.$('.s_countdown_canvas_wrapper');
+ this.hereBeforeTimerEnds = false;
+ this.endAction = this.el.dataset.endAction;
+ this.endTime = parseInt(this.el.dataset.endTime);
+ this.size = parseInt(this.el.dataset.size);
+ this.display = this.el.dataset.display;
+
+ this.layout = this.el.dataset.layout;
+ this.layoutBackground = this.el.dataset.layoutBackground;
+ this.progressBarStyle = this.el.dataset.progressBarStyle;
+ this.progressBarWeight = this.el.dataset.progressBarWeight;
+
+ this.textColor = this._ensureCssColor(this.el.dataset.textColor);
+ this.layoutBackgroundColor = this._ensureCssColor(this.el.dataset.layoutBackgroundColor);
+ this.progressBarColor = this._ensureCssColor(this.el.dataset.progressBarColor);
+
+ this.onlyOneUnit = this.display === 'd';
+ this.width = parseInt(this.size);
+ if (this.layout === 'boxes') {
+ this.width /= 1.75;
+ }
+ this._initTimeDiff();
+
+ this._render();
+
+ this.setInterval = setInterval(this._render.bind(this), 1000);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.$('.s_countdown_end_redirect_message').remove();
+ this.$('canvas').remove();
+ this.$('.s_countdown_end_message').addClass('d-none');
+ this.$('.s_countdown_text_wrapper').remove();
+ this.$('.s_countdown_canvas_wrapper').removeClass('d-none');
+
+ clearInterval(this.setInterval);
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Ensures the color is an actual css color. In case of a color variable,
+ * the color will be mapped to hexa.
+ *
+ * @private
+ * @param {string} color
+ * @returns {string}
+ */
+ _ensureCssColor: function (color) {
+ if (ColorpickerDialog.isCSSColor(color)) {
+ return color;
+ }
+ const style = window.getComputedStyle(document.documentElement);
+ return style.getPropertyValue('--' + color).trim();
+ },
+ /**
+ * Gets the time difference in seconds between now and countdown due date.
+ *
+ * @private
+ */
+ _getDelta: function () {
+ const currentTimestamp = Date.now() / 1000;
+ return this.endTime - currentTimestamp;
+ },
+ /**
+ * Handles the action that should be executed once the countdown ends.
+ *
+ * @private
+ */
+ _handleEndCountdownAction: function () {
+ if (this.endAction === 'redirect') {
+ const redirectUrl = this.el.dataset.redirectUrl;
+ if (this.hereBeforeTimerEnds) {
+ // Wait a bit, if the landing page has the same publish date
+ setTimeout(() => window.location = redirectUrl, 500);
+ } else {
+ // Show (non editable) msg when user lands on already finished countdown
+ if (!this.$('.s_countdown_end_redirect_message').length) {
+ this.$target.find('.container').append(
+ $(qweb.render('website.s_countdown.end_redirect_message', {
+ redirectUrl: redirectUrl,
+ }))
+ );
+ }
+ }
+ } else if (this.endAction === 'message') {
+ this.$('.s_countdown_end_message').removeClass('d-none');
+ }
+ },
+ /**
+ * Initializes the `diff` object. It will contains every visible time unit
+ * which will each contain its related canvas, total step, label..
+ *
+ * @private
+ */
+ _initTimeDiff: function () {
+ const delta = this._getDelta();
+ this.diff = [];
+ if (this._isUnitVisible('d') && !(this.onlyOneUnit && delta < 86400)) {
+ this.diff.push({
+ canvas: $('<canvas/>').appendTo(this.$wrapper)[0],
+ // There is no logical number of unit (total) on which day units
+ // can be compared against, so we use an arbitrary number.
+ total: 15,
+ label: _t("Days"),
+ nbSeconds: 86400,
+ });
+ }
+ if (this._isUnitVisible('h') || (this.onlyOneUnit && delta < 86400 && delta > 3600)) {
+ this.diff.push({
+ canvas: $('<canvas/>').appendTo(this.$wrapper)[0],
+ total: 24,
+ label: _t("Hours"),
+ nbSeconds: 3600,
+ });
+ }
+ if (this._isUnitVisible('m') || (this.onlyOneUnit && delta < 3600 && delta > 60)) {
+ this.diff.push({
+ canvas: $('<canvas/>').appendTo(this.$wrapper)[0],
+ total: 60,
+ label: _t("Minutes"),
+ nbSeconds: 60,
+ });
+ }
+ if (this._isUnitVisible('s') || (this.onlyOneUnit && delta < 60)) {
+ this.diff.push({
+ canvas: $('<canvas/>').appendTo(this.$wrapper)[0],
+ total: 60,
+ label: _t("Seconds"),
+ nbSeconds: 1,
+ });
+ }
+ },
+ /**
+ * Returns weither or not the countdown should be displayed for the given
+ * unit (days, sec..).
+ *
+ * @private
+ * @param {string} unit - either 'd', 'm', 'h', or 's'
+ * @returns {boolean}
+ */
+ _isUnitVisible: function (unit) {
+ return this.display.includes(unit);
+ },
+ /**
+ * Draws the whole countdown, including one countdown for each time unit.
+ *
+ * @private
+ */
+ _render: function () {
+ // If only one unit mode, restart widget on unit change to populate diff
+ if (this.onlyOneUnit && this._getDelta() < this.diff[0].nbSeconds) {
+ this.$('canvas').remove();
+ this._initTimeDiff();
+ }
+ this._updateTimeDiff();
+
+ const hideCountdown = this.isFinished && !this.editableMode && this.$el.hasClass('hide-countdown');
+ if (this.layout === 'text') {
+ this.$('canvas').addClass('d-none');
+ if (!this.$textWrapper) {
+ this.$textWrapper = $('<span/>').attr({
+ class: 's_countdown_text_wrapper d-none',
+ });
+ this.$textWrapper.text(_t("Countdown ends in"));
+ this.$textWrapper.append($('<span/>').attr({
+ class: 's_countdown_text ml-1',
+ }));
+ this.$textWrapper.appendTo(this.$wrapper);
+ }
+
+ this.$textWrapper.toggleClass('d-none', hideCountdown);
+
+ const countdownText = this.diff.map(e => e.nb + ' ' + e.label).join(', ');
+ this.$('.s_countdown_text').text(countdownText.toLowerCase());
+ } else {
+ for (const val of this.diff) {
+ const canvas = val.canvas;
+ const ctx = canvas.getContext("2d");
+ ctx.canvas.width = this.width;
+ ctx.canvas.height = this.size;
+ this._clearCanvas(ctx);
+
+ $(canvas).toggleClass('d-none', hideCountdown);
+ if (hideCountdown) {
+ return;
+ }
+
+ // Draw canvas elements
+ if (this.layoutBackground !== 'none') {
+ this._drawBgShape(ctx, this.layoutBackground === 'plain');
+ }
+ this._drawText(canvas, val.nb, val.label, this.layoutBackground === 'plain');
+ if (this.progressBarStyle === 'surrounded') {
+ this._drawProgressBarBg(ctx, this.progressBarWeight === 'thin');
+ }
+ if (this.progressBarStyle !== 'none') {
+ this._drawProgressBar(ctx, val.nb, val.total, this.progressBarWeight === 'thin');
+ }
+ $(canvas).toggleClass('mx-2', this.layout === 'boxes');
+ }
+ }
+
+ if (this.isFinished) {
+ clearInterval(this.setInterval);
+ if (!this.editableMode) {
+ this._handleEndCountdownAction();
+ }
+ }
+ },
+ /**
+ * Updates the remaining units into the `diff` object.
+ *
+ * @private
+ */
+ _updateTimeDiff: function () {
+ let delta = this._getDelta();
+ this.isFinished = delta < 0;
+ if (this.isFinished) {
+ for (const unitData of this.diff) {
+ unitData.nb = 0;
+ }
+ return;
+ }
+
+ this.hereBeforeTimerEnds = true;
+ for (const unitData of this.diff) {
+ unitData.nb = Math.floor(delta / unitData.nbSeconds);
+ delta -= unitData.nb * unitData.nbSeconds;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Canvas drawing methods
+ //--------------------------------------------------------------------------
+
+ /**
+ * Erases the canvas.
+ *
+ * @private
+ * @param {RenderingContext} ctx - Context of the canvas
+ */
+ _clearCanvas: function (ctx) {
+ ctx.clearRect(0, 0, this.size, this.size);
+ },
+ /**
+ * Draws a text into the canvas.
+ *
+ * @private
+ * @param {HTMLCanvasElement} canvas
+ * @param {string} textNb - text to display in the center of the canvas, in big
+ * @param {string} textUnit - text to display bellow `textNb` in small
+ * @param {boolean} full - if true, the shape will be drawn up to the progressbar
+ */
+ _drawText: function (canvas, textNb, textUnit, full = false) {
+ const ctx = canvas.getContext("2d");
+ const nbSize = this.size / 4;
+ ctx.font = `${nbSize}px Arial`;
+ ctx.fillStyle = this.textColor;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(textNb, canvas.width / 2, canvas.height / 2);
+
+ const unitSize = this.size / 12;
+ ctx.font = `${unitSize}px Arial`;
+ ctx.fillText(textUnit, canvas.width / 2, canvas.height / 2 + nbSize / 1.5, this.width);
+
+ if (this.layout === 'boxes' && this.layoutBackground !== 'none' && this.progressBarStyle === 'none') {
+ let barWidth = this.size / (this.progressBarWeight === 'thin' ? 31 : 10);
+ if (full) {
+ barWidth = 0;
+ }
+ ctx.beginPath();
+ ctx.moveTo(barWidth, this.size / 2);
+ ctx.lineTo(this.width - barWidth, this.size / 2);
+ ctx.stroke();
+ }
+ },
+ /**
+ * Draws a plain shape into the canvas.
+ *
+ * @private
+ * @param {RenderingContext} ctx - Context of the canvas
+ * @param {boolean} full - if true, the shape will be drawn up to the progressbar
+ */
+ _drawBgShape: function (ctx, full = false) {
+ ctx.fillStyle = this.layoutBackgroundColor;
+ ctx.beginPath();
+ if (this.layout === 'circle') {
+ let rayon = this.size / 2;
+ if (this.progressBarWeight === 'thin') {
+ rayon -= full ? this.size / 29 : this.size / 15;
+ } else {
+ rayon -= full ? 0 : this.size / 10;
+ }
+ ctx.arc(this.size / 2, this.size / 2, rayon, 0, Math.PI * 2);
+ ctx.fill();
+ } else if (this.layout === 'boxes') {
+ let barWidth = this.size / (this.progressBarWeight === 'thin' ? 31 : 10);
+ if (full) {
+ barWidth = 0;
+ }
+
+ ctx.fillStyle = this.layoutBackgroundColor;
+ ctx.rect(barWidth, barWidth, this.width - barWidth * 2, this.size - barWidth * 2);
+ ctx.fill();
+
+ const gradient = ctx.createLinearGradient(0, this.width, 0, 0);
+ gradient.addColorStop(0, '#ffffff24');
+ gradient.addColorStop(1, this.layoutBackgroundColor);
+ ctx.fillStyle = gradient;
+ ctx.rect(barWidth, barWidth, this.width - barWidth * 2, this.size - barWidth * 2);
+ ctx.fill();
+ $(ctx.canvas).css({'border-radius': '8px'});
+ }
+ },
+ /**
+ * Draws a progress bar around the countdown shape.
+ *
+ * @private
+ * @param {RenderingContext} ctx - Context of the canvas
+ * @param {string} nbUnit - how many unit should fill progress bar
+ * @param {string} totalUnit - number of unit to do a complete progress bar
+ * @param {boolean} thinLine - if true, the progress bar will be thiner
+ */
+ _drawProgressBar: function (ctx, nbUnit, totalUnit, thinLine) {
+ ctx.strokeStyle = this.progressBarColor;
+ ctx.lineWidth = thinLine ? this.size / 35 : this.size / 10;
+ if (this.layout === 'circle') {
+ ctx.beginPath();
+ ctx.arc(this.size / 2, this.size / 2, this.size / 2 - this.size / 20, Math.PI / -2, (Math.PI * 2) * (nbUnit / totalUnit) + (Math.PI / -2));
+ ctx.stroke();
+ } else if (this.layout === 'boxes') {
+ ctx.lineWidth *= 2;
+ let pc = nbUnit / totalUnit * 100;
+
+ // Lines: Top(x1,y1,x2,y2) Right(x1,y1,x2,y2) Bottom(x1,y1,x2,y2) Left(x1,y1,x2,y2)
+ const linesCoordFuncs = [
+ (linePc) => [0 + ctx.lineWidth / 2, 0, (this.width - ctx.lineWidth / 2) * linePc / 25 + ctx.lineWidth / 2, 0],
+ (linePc) => [this.width, 0 + ctx.lineWidth / 2, this.width, (this.size - ctx.lineWidth / 2) * linePc / 25 + ctx.lineWidth / 2],
+ (linePc) => [this.width - ((this.width - ctx.lineWidth / 2) * linePc / 25) - ctx.lineWidth / 2, this.size, this.width - ctx.lineWidth / 2, this.size],
+ (linePc) => [0, this.size - ((this.size - ctx.lineWidth / 2) * linePc / 25) - ctx.lineWidth / 2, 0, this.size - ctx.lineWidth / 2],
+ ];
+ while (pc > 0 && linesCoordFuncs.length) {
+ const linePc = Math.min(pc, 25);
+ const lineCoord = (linesCoordFuncs.shift())(linePc);
+ ctx.beginPath();
+ ctx.moveTo(lineCoord[0], lineCoord[1]);
+ ctx.lineTo(lineCoord[2], lineCoord[3]);
+ ctx.stroke();
+ pc -= linePc;
+ }
+ }
+ },
+ /**
+ * Draws a full lighter background progressbar around the shape.
+ *
+ * @private
+ * @param {RenderingContext} ctx - Context of the canvas
+ * @param {boolean} thinLine - if true, the progress bar will be thiner
+ */
+ _drawProgressBarBg: function (ctx, thinLine) {
+ ctx.strokeStyle = this.progressBarColor;
+ ctx.globalAlpha = 0.2;
+ ctx.lineWidth = thinLine ? this.size / 35 : this.size / 10;
+ if (this.layout === 'circle') {
+ ctx.beginPath();
+ ctx.arc(this.size / 2, this.size / 2, this.size / 2 - this.size / 20, 0, Math.PI * 2);
+ ctx.stroke();
+ } else if (this.layout === 'boxes') {
+ ctx.lineWidth *= 2;
+
+ // Lines: Top(x1,y1,x2,y2) Right(x1,y1,x2,y2) Bottom(x1,y1,x2,y2) Left(x1,y1,x2,y2)
+ const points = [
+ [0 + ctx.lineWidth / 2, 0, this.width, 0],
+ [this.width, 0 + ctx.lineWidth / 2, this.width, this.size],
+ [0, this.size, this.width - ctx.lineWidth / 2, this.size],
+ [0, 0, 0, this.size - ctx.lineWidth / 2],
+ ];
+ while (points.length) {
+ const point = points.shift();
+ ctx.beginPath();
+ ctx.moveTo(point[0], point[1]);
+ ctx.lineTo(point[2], point[3]);
+ ctx.stroke();
+ }
+ }
+ ctx.globalAlpha = 1;
+ },
+});
+
+registry.chart = publicWidget.Widget.extend({
+ selector: '.s_chart',
+ disabledInEditableMode: false,
+ jsLibs: [
+ '/web/static/lib/Chart/Chart.js',
+ ],
+
+ /**
+ * @override
+ * @param {Object} parent
+ * @param {Object} options The default value of the chartbar.
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.style = window.getComputedStyle(document.documentElement);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ // Convert Theme colors to css color
+ const data = JSON.parse(this.el.dataset.data);
+ data.datasets.forEach(el => {
+ if (Array.isArray(el.backgroundColor)) {
+ el.backgroundColor = el.backgroundColor.map(el => this._convertToCssColor(el));
+ el.borderColor = el.borderColor.map(el => this._convertToCssColor(el));
+ } else {
+ el.backgroundColor = this._convertToCssColor(el.backgroundColor);
+ el.borderColor = this._convertToCssColor(el.borderColor);
+ }
+ el.borderWidth = this.el.dataset.borderWidth;
+ });
+
+ // Make chart data
+ const chartData = {
+ type: this.el.dataset.type,
+ data: data,
+ options: {
+ legend: {
+ display: this.el.dataset.legendPosition !== 'none',
+ position: this.el.dataset.legendPosition,
+ },
+ tooltips: {
+ enabled: this.el.dataset.tooltipDisplay === 'true',
+ },
+ title: {
+ display: !!this.el.dataset.title,
+ text: this.el.dataset.title,
+ },
+ },
+ };
+
+ // Add type specific options
+ if (this.el.dataset.type === 'radar') {
+ chartData.options.scale = {
+ ticks: {
+ beginAtZero: true,
+ }
+ };
+ } else if (['pie', 'doughnut'].includes(this.el.dataset.type)) {
+ chartData.options.tooltips.callbacks = {
+ label: (tooltipItem, data) => {
+ const label = data.datasets[tooltipItem.datasetIndex].label;
+ const secondLabel = data.labels[tooltipItem.index];
+ let final = label;
+ if (label) {
+ if (secondLabel) {
+ final = label + ' - ' + secondLabel;
+ }
+ } else if (secondLabel) {
+ final = secondLabel;
+ }
+ return final + ':' + data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];
+ },
+ };
+ } else {
+ chartData.options.scales = {
+ xAxes: [{
+ stacked: this.el.dataset.stacked === 'true',
+ ticks: {
+ beginAtZero: true
+ },
+ }],
+ yAxes: [{
+ stacked: this.el.dataset.stacked === 'true',
+ ticks: {
+ beginAtZero: true
+ },
+ }],
+ };
+ }
+
+ // Disable animation in edit mode
+ if (this.editableMode) {
+ chartData.options.animation = {
+ duration: 0,
+ };
+ }
+
+ const canvas = this.el.querySelector('canvas');
+ this.chart = new window.Chart(canvas, chartData);
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ * Discard all library changes to reset the state of the Html.
+ */
+ destroy: function () {
+ if (this.chart) { // The widget can be destroyed before start has completed
+ this.chart.destroy();
+ this.el.querySelectorAll('.chartjs-size-monitor').forEach(el => el.remove());
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} color A css color or theme color string
+ * @returns {string} Css color
+ */
+ _convertToCssColor: function (color) {
+ if (!color) {
+ return 'transparent';
+ }
+ return this.style.getPropertyValue(`--${color}`).trim() || color;
+ },
+});
+
+return {
+ Widget: publicWidget.Widget,
+ Animation: Animation,
+ registry: registry,
+
+ Class: Animation, // Deprecated
+};
+});
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 00000000000..47ba28bd791
--- /dev/null
+++ b/addons/website/static/src/js/editor/snippets.options.js
@@ -0,0 +1,1888 @@
+odoo.define('website.editor.snippets.options', function (require) {
+'use strict';
+
+var core = require('web.core');
+const ColorpickerDialog = require('web.ColorpickerDialog');
+var Dialog = require('web.Dialog');
+var weWidgets = require('wysiwyg.widgets');
+const wUtils = require('website.utils');
+var options = require('web_editor.snippets.options');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+options.Class.include({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Refreshes all public widgets related to the given element.
+ *
+ * @private
+ * @param {jQuery} [$el=this.$target]
+ * @returns {Promise}
+ */
+ _refreshPublicWidgets: function ($el) {
+ return new Promise((resolve, reject) => {
+ this.trigger_up('widgets_start_request', {
+ editableMode: true,
+ $target: $el || this.$target,
+ onSuccess: resolve,
+ onFailure: reject,
+ });
+ });
+ },
+ /**
+ * @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();
+ }
+ },
+});
+
+options.registry.background.include({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getDefaultTextContent: function () {
+ if (this._getMediaDialogOptions().noVideos) {
+ return this._super(...arguments);
+ }
+ return _t("Choose a picture or a video");
+ },
+ /**
+ * @override
+ */
+ _getEditableMedia: function () {
+ if (!this._hasBgvideo()) {
+ return this._super(...arguments);
+ }
+ return this.$('.o_bg_video_iframe')[0];
+ },
+ /**
+ * @override
+ */
+ _getMediaDialogOptions: function () {
+ return _.extend(this._super(...arguments), {
+ // For now, disable the possibility to have a parallax video bg
+ noVideos: this.$target.is('.parallax, .s_parallax_bg'),
+ isForBgVideo: true,
+ });
+ },
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'chooseImage') {
+ return this._hasBgvideo() ? 'true' : '';
+ }
+ 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;
+ }
+
+ 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();
+ await this.updateUI();
+ },
+ /**
+ * Returns whether the current target has a background video or not.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _hasBgvideo: function () {
+ return this.$target[0].classList.contains('o_background_video');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onBackgroundColorUpdate: function (ev, previewMode) {
+ var ret = this._super(...arguments);
+ if (ret) {
+ this._setBgVideo(previewMode);
+ }
+ return ret;
+ },
+ /**
+ * @override
+ */
+ _onSaveMediaDialog: async function (data) {
+ if (!data.bgVideoSrc) {
+ const _super = this._super.bind(this);
+ const args = arguments;
+ await this._setBgVideo(false);
+ return _super(...args);
+ }
+ // if the user chose a video, only add the video without removing the
+ // background
+ await this._setBgVideo(false, data.bgVideoSrc);
+ },
+});
+
+options.registry.menu_data = options.Class.extend({
+ xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
+
+ /**
+ * 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);
+ this.$target.find('[data-slide]').attr('href', '#' + id);
+ },
+});
+
+options.registry.CarouselItem = options.Class.extend({
+ isTopOption: 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);
+
+ leftPanelEl.querySelector('.oe_snippet_remove').classList.add('d-none'); // TODO improve the way to do that
+
+ 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.$target.attr('id'),
+ 'data-slide-to': $items.length,
+ }));
+ // 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.navTabs = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this._findLinksAndPanes();
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._generateUniqueIDs();
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this._generateUniqueIDs();
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates a new tab and tab-pane.
+ *
+ * @see this.selectClass for parameters
+ */
+ addTab: function (previewMode, widgetValue, params) {
+ var $activeItem = this.$navLinks.filter('.active').parent();
+ var $activePane = this.$tabPanes.filter('.active');
+
+ var $navItem = $activeItem.clone();
+ var $navLink = $navItem.find('.nav-link').removeClass('active show');
+ var $tabPane = $activePane.clone().removeClass('active show');
+ $navItem.insertAfter($activeItem);
+ $tabPane.insertAfter($activePane);
+ this._findLinksAndPanes();
+ this._generateUniqueIDs();
+
+ $navLink.tab('show');
+ },
+ /**
+ * Removes the current active tab and its content.
+ *
+ * @see this.selectClass for parameters
+ */
+ removeTab: function (previewMode, widgetValue, params) {
+ var self = this;
+
+ var $activeLink = this.$navLinks.filter('.active');
+ var $activePane = this.$tabPanes.filter('.active');
+
+ var $next = this.$navLinks.eq((this.$navLinks.index($activeLink) + 1) % this.$navLinks.length);
+
+ return new Promise(resolve => {
+ $next.one('shown.bs.tab', function () {
+ $activeLink.parent().remove();
+ $activePane.remove();
+ self._findLinksAndPanes();
+ resolve();
+ });
+ $next.tab('show');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetVisibility: async function (widgetName, params) {
+ if (widgetName === 'remove_tab_opt') {
+ return (this.$tabPanes.length > 2);
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ */
+ _findLinksAndPanes: function () {
+ this.$navLinks = this.$target.find('.nav-link');
+ var $el = this.$target;
+ do {
+ $el = $el.parent();
+ this.$tabPanes = $el.find('.tab-pane');
+ } while (this.$tabPanes.length === 0 && !$el.is('body'));
+ },
+ /**
+ * @private
+ */
+ _generateUniqueIDs: function () {
+ for (var i = 0; i < this.$navLinks.length; i++) {
+ var id = _.now() + '_' + _.uniqueId();
+ var idLink = 'nav_tabs_link_' + id;
+ var idContent = 'nav_tabs_content_' + id;
+ this.$navLinks.eq(i).attr({
+ 'id': idLink,
+ 'href': '#' + idContent,
+ 'aria-controls': idContent,
+ });
+ this.$tabPanes.eq(i).attr({
+ 'id': idContent,
+ 'aria-labelledby': idLink,
+ });
+ }
+ },
+});
+
+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({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Changes the number of columns.
+ *
+ * @see this.selectClass for parameters
+ */
+ selectCount: function (previewMode, widgetValue, params) {
+ const nbColumns = parseInt(widgetValue);
+ this._updateColumnCount(nbColumns - this.$target.children().length);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'selectCount') {
+ return '' + this.$target.children().length;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Adds new columns which are clones of the last column or removes the
+ * last x columns.
+ *
+ * @private
+ * @param {integer} count - positif to add, negative to remove
+ */
+ _updateColumnCount: function (count) {
+ if (!count) {
+ return;
+ }
+
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+
+ if (count > 0) {
+ var $lastColumn = this.$target.children().last();
+ for (var i = 0; i < count; i++) {
+ $lastColumn.clone().insertAfter($lastColumn);
+ }
+ } else {
+ var self = this;
+ _.each(this.$target.children().slice(count), function (el) {
+ self.trigger_up('remove_snippet', {$snippet: $(el)});
+ });
+ }
+
+ this._resizeColumns();
+ this.trigger_up('cover_update');
+ },
+ /**
+ * Resizes the columns so that they are kept on one row.
+ *
+ * @private
+ */
+ _resizeColumns: function () {
+ var $columns = this.$target.children();
+ var 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
+ */
+ onFocus: function () {
+ this.trigger_up('option_update', {
+ optionNames: ['background', 'BackgroundPosition'],
+ name: 'target',
+ data: this.$target.find('> .s_parallax_bg'),
+ });
+ // 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.
+ this._refreshPublicWidgets();
+ },
+ /**
+ * @override
+ */
+ onMove: function () {
+ this._refreshPublicWidgets();
+ },
+});
+
+options.registry.ul = options.Class.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$target.on('mouseup', '.o_ul_toggle_self, .o_ul_toggle_next', function () {
+ self.trigger_up('cover_update');
+ });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ cleanForSave: function () {
+ this._super();
+ if (!this.$target.hasClass('o_ul_folded')) {
+ this.$target.find('.o_close').removeClass('o_close');
+ this.$target.find('li').css('list-style', '');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ selectClass: async function () {
+ await this._super.apply(this, arguments);
+
+ this.trigger_up('widgets_stop_request', {
+ $target: this.$target,
+ });
+
+ this.$target.find('.o_ul_toggle_self, .o_ul_toggle_next').remove();
+ this.$target.find('li:has(>ul,>ol)').map(function () {
+ // get if the li contain a text label
+ var texts = _.filter(_.toArray(this.childNodes), a => (a.nodeType === 3));
+ if (!texts.length || !texts.reduce((a, b) => (a.textContent + b.textContent)).match(/\S/)) {
+ return;
+ }
+ $(this).children('ul,ol').addClass('o_close');
+ return $(this).children(':not(ul,ol)')[0] || this;
+ })
+ .prepend('<a href="#" class="o_ul_toggle_self fa" />');
+ var $li = this.$target.find('li:has(+li:not(>.o_ul_toggle_self)>ul, +li:not(>.o_ul_toggle_self)>ol)');
+ $li.css('list-style', this.$target.hasClass('o_ul_folded') ? 'none' : '');
+ $li.map((i, el) => ($(el).children()[0] || el))
+ .prepend('<a href="#" class="o_ul_toggle_next fa" />');
+ $li.removeClass('o_open').next().addClass('o_close');
+ this.$target.find('li').removeClass('o_open');
+ },
+});
+
+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');
+ });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._createIDs();
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this.$target.find('[data-toggle="collapse"]').removeAttr('data-target').removeData('target');
+ this.$target.find('.collapse').removeAttr('id');
+ 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 () {
+ var time = new Date().getTime();
+ var $tab = this.$target.find('[data-toggle="collapse"]');
+
+ // link to the parent group
+ var $tablist = this.$target.closest('.accordion');
+ var tablist_id = $tablist.attr('id');
+ if (!tablist_id) {
+ tablist_id = 'myCollapse' + time;
+ $tablist.attr('id', tablist_id);
+ }
+ $tab.attr('data-parent', '#' + tablist_id);
+ $tab.data('parent', '#' + tablist_id);
+
+ // link to the collapse
+ var $panel = this.$target.find('.collapse');
+ var panel_id = $panel.attr('id');
+ if (!panel_id) {
+ while ($('#' + (panel_id = 'myCollapseTab' + time)).length) {
+ time++;
+ }
+ $panel.attr('id', panel_id);
+ }
+ $tab.attr('data-target', '#' + panel_id);
+ $tab.data('target', '#' + panel_id);
+ },
+});
+
+options.registry.countdown = options.Class.extend({
+ events: _.extend({}, options.Class.prototype.events || {}, {
+ 'click .toggle-edit-message': '_onToggleEndMessageClick',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Changes the countdown action at zero.
+ *
+ * @see this.selectClass for parameters
+ */
+ endAction: function (previewMode, widgetValue, params) {
+ this.$target[0].dataset.endAction = widgetValue;
+ if (widgetValue === 'message') {
+ if (!this.$target.find('.s_countdown_end_message').length) {
+ const message = this.endMessage || qweb.render('website.s_countdown.end_message');
+ this.$target.find('.container').append(message);
+ }
+ } else {
+ const $message = this.$target.find('.s_countdown_end_message').detach();
+ if ($message.length) {
+ this.endMessage = $message[0].outerHTML;
+ }
+ }
+ },
+ /**
+ * Changes the countdown style.
+ *
+ * @see this.selectClass for parameters
+ */
+ layout: function (previewMode, widgetValue, params) {
+ switch (widgetValue) {
+ case 'circle':
+ this.$target[0].dataset.progressBarStyle = 'disappear';
+ this.$target[0].dataset.progressBarWeight = 'thin';
+ this.$target[0].dataset.layoutBackground = 'none';
+ break;
+ case 'boxes':
+ this.$target[0].dataset.progressBarStyle = 'none';
+ this.$target[0].dataset.layoutBackground = 'plain';
+ break;
+ case 'clean':
+ this.$target[0].dataset.progressBarStyle = 'none';
+ this.$target[0].dataset.layoutBackground = 'none';
+ break;
+ case 'text':
+ this.$target[0].dataset.progressBarStyle = 'none';
+ this.$target[0].dataset.layoutBackground = 'none';
+ break;
+ }
+ this.$target[0].dataset.layout = widgetValue;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ updateUIVisibility: async function () {
+ await this._super(...arguments);
+ const dataset = this.$target[0].dataset;
+
+ // End Action UI
+ this.$el.find('.toggle-edit-message')
+ .toggleClass('d-none', dataset.endAction !== 'message');
+
+ // End Message UI
+ this.updateUIEndMessage();
+ },
+ /**
+ * @see this.updateUI
+ */
+ updateUIEndMessage: function () {
+ this.$target.find('.s_countdown_canvas_wrapper')
+ .toggleClass("d-none", this.showEndMessage === true && this.$target.hasClass("hide-countdown"));
+ this.$target.find('.s_countdown_end_message')
+ .toggleClass("d-none", !this.showEndMessage);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'endAction':
+ case 'layout':
+ return this.$target[0].dataset[methodName];
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onToggleEndMessageClick: function () {
+ this.showEndMessage = !this.showEndMessage;
+ this.$el.find(".toggle-edit-message")
+ .toggleClass('text-primary', this.showEndMessage);
+ this.updateUIEndMessage();
+ this.trigger_up('cover_update');
+ },
+});
+
+options.registry.topMenuTransparency = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles the toggling between normal and overlay positions of the header.
+ *
+ * @see this.selectClass for params
+ */
+ transparent: function (previewMode, widgetValue, params) {
+ var self = this;
+ this.trigger_up('action_demand', {
+ actionName: 'toggle_page_option',
+ params: [{name: 'header_overlay'}],
+ onSuccess: function () {
+ self.trigger_up('action_demand', {
+ actionName: 'toggle_page_option',
+ params: [{name: 'header_color', value: ''}],
+ });
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'transparent') {
+ return new Promise(resolve => {
+ this.trigger_up('action_demand', {
+ actionName: 'get_page_option',
+ params: ['header_overlay'],
+ onSuccess: v => resolve(v ? 'true' : ''),
+ });
+ });
+ }
+ return this._super(...arguments);
+ },
+});
+
+options.registry.topMenuColor = options.Class.extend({
+
+ //--------------------------------------------------------------------------
+ // 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),
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onColorButtonClick: function () {
+ this._super.apply(this, arguments);
+ var bgs = this.$target.attr('class').match(/bg-(\w|-)+/g);
+ var allowedBgs = this.classes.split(' ');
+ var color = _.intersection(bgs, allowedBgs).join(' ');
+ this.trigger_up('action_demand', {
+ actionName: 'toggle_page_option',
+ params: [{name: 'header_color', value: color}],
+ });
+ },
+});
+
+/**
+ * Handles the edition of snippet's anchor name.
+ */
+options.registry.anchor = options.Class.extend({
+ xmlDependencies: ['/website/static/src/xml/website.editor.xml'],
+ 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({
+ title: _t("Copied !"),
+ message: _.str.sprintf(_t("The anchor has been copied to your clipboard.<br>Link: %s"), anchor),
+ buttons: [{text: _t("edit"), click: () => this.openAnchorDialog()}],
+ });
+ });
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onClone: function () {
+ this.$target.removeAttr('id data-anchor');
+ },
+
+ //--------------------------------------------------------------------------
+ // 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 `#${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, '-'));
+ },
+});
+
+/**
+ * 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]');
+ this.$filterColorOpts = this.$el.find('[data-filter-color]');
+ this.filterColorClasses = this.$filterColorOpts.map(function () {
+ return $(this).data('filterColor');
+ }).get().join(' ');
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ clear: function (previewMode, widgetValue, params) {
+ this.$target.removeClass('o_record_has_cover');
+ this.$image.css('background-image', '');
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ change: function (previewMode, widgetValue, params) {
+ var $image = $('<img/>');
+ var background = this.$image.css('background-image');
+ if (background && background !== 'none') {
+ $image.attr('src', background.match(/^url\(["']?(.+?)["']?\)$/)[1]);
+ }
+
+ return new Promise(resolve => {
+ var editor = new weWidgets.MediaDialog(this, {
+ mediaWidth: 1920,
+ onlyImages: true,
+ firstFilters: ['background']
+ }, $image[0]).open();
+ editor.on('save', this, function (image) {
+ var src = image.src;
+ this.$image.css('background-image', src ? ('url(' + src + ')') : '');
+ if (!this.$target.hasClass('o_record_has_cover')) {
+ this.$el.find('.o_record_cover_opt_size_default[data-select-class]').click();
+ }
+ resolve();
+ });
+ });
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ filterValue: function (previewMode, widgetValue, params) {
+ this.$filter.css('opacity', widgetValue || 0);
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ filterColor: function (previewMode, widgetValue, params) {
+ this.$filter.removeClass(this.filterColorClasses);
+ if (widgetValue) {
+ this.$filter.addClass(widgetValue);
+ }
+
+ var $firstVisibleFilterOpt = this.$filterValueOpts.eq(1);
+ if (parseFloat(this.$filter.css('opacity')) < parseFloat($firstVisibleFilterOpt.data('filterValue'))) {
+ this.filterValue(previewMode, $firstVisibleFilterOpt.data('filterValue'), $firstVisibleFilterOpt);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ updateUI: async function () {
+ await this._super(...arguments);
+
+ // Update saving dataset
+ this.$target[0].dataset.coverClass = this.$el.find('[data-cover-opt-name="size"] .active').data('selectClass') || '';
+ this.$target[0].dataset.textSizeClass = this.$el.find('[data-cover-opt-name="text_size"] .active').data('selectClass') || '';
+ this.$target[0].dataset.textAlignClass = this.$el.find('[data-cover-opt-name="text_align"] .active').data('selectClass') || '';
+ this.$target[0].dataset.filterValue = this.$filterValueOpts.filter('.active').data('filterValue') || 0.0;
+ this.$target[0].dataset.filterColor = this.$filterColorOpts.filter('.active').data('filterColor') || '';
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'filterValue': {
+ return parseFloat(this.$filter.css('opacity')).toFixed(1);
+ }
+ case 'filterColor': {
+ const classes = this.filterColorClasses.split(' ');
+ for (const className of classes) {
+ if (this.$filter.hasClass(className)) {
+ return className;
+ }
+ }
+ return '';
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetVisibility: function (widgetName, params) {
+ const hasCover = this.$target.hasClass('o_record_has_cover');
+ if (widgetName === 'remove_cover_opt') {
+ return hasCover;
+ } else if (params.coverOptName) {
+ var notAllowed = (this.$target.data(`use_${params.coverOptName}`) !== 'True');
+ return (hasCover && !notAllowed);
+ }
+ return this._super(...arguments);
+ },
+});
+
+/**
+ * Whether the section should be full-width (container-fluid) or use a classic container
+ */
+options.registry.SectionStretch = options.Class.extend({
+ /**
+ * Only shows this option if it changes the visual.
+ *
+ * @override
+ */
+ start: function () {
+ const $container = $('<div>', {class: 'container'}).insertAfter(this.$target);
+ const sizeDifference = this.$target.parent().width() / $container.outerWidth() - 1;
+ $container.remove();
+ // The cutoff for the option is 5% difference in width
+ if (sizeDifference < 0.05) {
+ this.$el.addClass('d-none');
+ }
+
+ return this._super.apply(this, arguments);
+ },
+});
+
+/**
+ * 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_options');
+ $overlayArea.prepend($buttons[0]);
+ $overlayArea.append($buttons[1]);
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Moves the snippet around.
+ *
+ * @see this.selectClass for parameters
+ */
+ moveSnippet: function (previewMode, widgetValue, params) {
+ switch (widgetValue) {
+ case 'prev':
+ this.$target.prev().before(this.$target);
+ break;
+ case 'next':
+ this.$target.next().after(this.$target);
+ break;
+ }
+ },
+});
+
+options.registry.InnerChart = options.Class.extend({
+ custom_events: _.extend({}, options.Class.prototype.custom_events, {
+ 'get_custom_colors': '_onGetCustomColors',
+ }),
+ events: _.extend({}, options.Class.prototype.events, {
+ 'click we-button.add_column': '_onAddColumnClick',
+ 'click we-button.add_row': '_onAddRowClick',
+ 'click we-button.o_we_matrix_remove_col': '_onRemoveColumnClick',
+ 'click we-button.o_we_matrix_remove_row': '_onRemoveRowClick',
+ 'blur we-matrix input': '_onMatrixInputFocusOut',
+ 'focus we-matrix input': '_onMatrixInputFocus',
+ }),
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.themeArray = ['alpha', 'beta', 'gamma', 'delta', 'epsilon'];
+ this.style = window.getComputedStyle(document.documentElement);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.backSelectEl = this.el.querySelector('[data-name="chart_bg_color_opt"]');
+ this.borderSelectEl = this.el.querySelector('[data-name="chart_border_color_opt"]');
+
+ // Build matrix content
+ this.tableEl = this.el.querySelector('we-matrix table');
+ const data = JSON.parse(this.$target[0].dataset.data);
+ data.labels.forEach(el => {
+ this._addRow(el);
+ });
+ data.datasets.forEach((el, i) => {
+ if (this._isPieChart()) {
+ // Add header colors in case the user changes the type of graph
+ const headerBackgroundColor = this.themeArray[i] || this._randomColor();
+ const headerBorderColor = this.themeArray[i] || this._randomColor();
+ this._addColumn(el.label, el.data, headerBackgroundColor, headerBorderColor, el.backgroundColor, el.borderColor);
+ } else {
+ this._addColumn(el.label, el.data, el.backgroundColor, el.borderColor);
+ }
+ });
+ this._displayRemoveColButton();
+ this._displayRemoveRowButton();
+ this._setDefaultSelectedInput();
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ updateUI: async function () {
+ // Selected input might not be in dom anymore if col/row removed
+ // Done before _super because _computeWidgetState of colorChange
+ if (!this.lastEditableSelectedInput.closest('table') || this.colorPaletteSelectedInput && !this.colorPaletteSelectedInput.closest('table')) {
+ this._setDefaultSelectedInput();
+ }
+
+ await this._super(...arguments);
+
+ // prevent the columns from becoming too small.
+ this.tableEl.classList.toggle('o_we_matrix_five_col', this.tableEl.querySelectorAll('tr:first-child th').length > 5);
+
+ this.backSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Background Color") : _t("Dataset Background Color");
+ this.borderSelectEl.querySelector('we-title').textContent = this._isPieChart() ? _t("Data Border Color") : _t("Dataset Border Color");
+
+ // Dataset/Cell color
+ this.tableEl.querySelectorAll('input').forEach(el => el.style.border = '');
+ const selector = this._isPieChart() ? 'td input' : 'tr:first-child input';
+ this.tableEl.querySelectorAll(selector).forEach(el => {
+ const color = el.dataset.backgroundColor || el.dataset.borderColor;
+ if (color) {
+ el.style.border = '2px solid';
+ el.style.borderColor = ColorpickerDialog.isCSSColor(color) ? color : this.style.getPropertyValue(`--${color}`).trim();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Set the color on the selected input.
+ */
+ colorChange: async function (previewMode, widgetValue, params) {
+ if (widgetValue) {
+ this.colorPaletteSelectedInput.dataset[params.attributeName] = widgetValue;
+ } else {
+ delete this.colorPaletteSelectedInput.dataset[params.attributeName];
+ }
+ await this._reloadGraph();
+ // To focus back the input that is edited we have to wait for the color
+ // picker to be fully reloaded.
+ await new Promise(resolve => setTimeout(() => {
+ this.lastEditableSelectedInput.focus();
+ resolve();
+ }));
+ },
+ /**
+ * @override
+ */
+ selectDataAttribute: async function (previewMode, widgetValue, params) {
+ await this._super(...arguments);
+ // Data might change if going from or to a pieChart.
+ if (params.attributeName === 'type') {
+ this._setDefaultSelectedInput();
+ await this._reloadGraph();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'colorChange') {
+ return this.colorPaletteSelectedInput && this.colorPaletteSelectedInput.dataset[params.attributeName] || '';
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetVisibility: function (widgetName, params) {
+ switch (widgetName) {
+ case 'stacked_chart_opt': {
+ return this._getColumnCount() > 1;
+ }
+ case 'chart_bg_color_opt':
+ case 'chart_border_color_opt': {
+ return !!this.colorPaletteSelectedInput;
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Sets and reloads the data on the canvas if it has changed.
+ * Used in matrix related method.
+ *
+ * @private
+ */
+ _reloadGraph: async function () {
+ const jsonValue = this._matrixToChartData();
+ if (this.$target[0].dataset.data !== jsonValue) {
+ this.$target[0].dataset.data = jsonValue;
+ await this._refreshPublicWidgets();
+ }
+ },
+ /**
+ * Return a stringifyed chart.js data object from the matrix
+ * Pie charts have one color per data while other charts have one color per dataset.
+ *
+ * @private
+ */
+ _matrixToChartData: function () {
+ const data = {
+ labels: [],
+ datasets: [],
+ };
+ this.tableEl.querySelectorAll('tr:first-child input').forEach(el => {
+ data.datasets.push({
+ label: el.value || '',
+ data: [],
+ backgroundColor: this._isPieChart() ? [] : el.dataset.backgroundColor || '',
+ borderColor: this._isPieChart() ? [] : el.dataset.borderColor || '',
+ });
+ });
+ this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el) => {
+ const title = el.querySelector('th input').value || '';
+ data.labels.push(title);
+ el.querySelectorAll('td input').forEach((el, i) => {
+ data.datasets[i].data.push(el.value || 0);
+ if (this._isPieChart()) {
+ data.datasets[i].backgroundColor.push(el.dataset.backgroundColor || '');
+ data.datasets[i].borderColor.push(el.dataset.borderColor || '');
+ }
+ });
+ });
+ return JSON.stringify(data);
+ },
+ /**
+ * Return a td containing a we-button with minus icon
+ *
+ * @param {...string} classes Classes to add to the we-button
+ * @returns {HTMLElement}
+ */
+ _makeDeleteButton: function (...classes) {
+ const rmbuttonEl = options.buildElement('we-button', null, {
+ classes: ['fa', 'fa-fw', 'fa-minus', ...classes],
+ });
+ const newEl = document.createElement('td');
+ newEl.appendChild(rmbuttonEl);
+ return newEl;
+ },
+ /**
+ * Add a column to the matrix
+ * The th (dataset label) of a column hold the colors for the entire dataset if the graph is not a pie chart
+ * If the graph is a pie chart the color of the td (data) are used.
+ *
+ * @private
+ * @param {String} title The title of the column
+ * @param {Array} values The values of the column input
+ * @param {String} heardeBackgroundColor The background color of the dataset
+ * @param {String} headerBorderColor The border color of the dataset
+ * @param {string[]} cellBackgroundColors The background colors of the datas inputs, random color if missing
+ * @param {string[]} cellBorderColors The border color of the datas inputs, no color if missing
+ */
+ _addColumn: function (title, values, heardeBackgroundColor, headerBorderColor, cellBackgroundColors = [], cellBorderColors = []) {
+ const firstRow = this.tableEl.querySelector('tr:first-child');
+ const headerInput = this._makeCell('th', title, heardeBackgroundColor, headerBorderColor);
+ firstRow.insertBefore(headerInput, firstRow.lastElementChild);
+
+ this.tableEl.querySelectorAll('tr:not(:first-child):not(:last-child)').forEach((el, i) => {
+ const newCell = this._makeCell('td', values ? values[i] : null, cellBackgroundColors[i] || this._randomColor(), cellBorderColors[i - 1]);
+ el.insertBefore(newCell, el.lastElementChild);
+ });
+
+ const lastRow = this.tableEl.querySelector('tr:last-child');
+ const removeButton = this._makeDeleteButton('o_we_matrix_remove_col');
+ lastRow.appendChild(removeButton);
+ },
+ /**
+ * Add a row to the matrix
+ * The background color of the datas are random
+ *
+ * @private
+ * @param {String} tilte The title of the row
+ */
+ _addRow: function (tilte) {
+ const trEl = document.createElement('tr');
+ trEl.appendChild(this._makeCell('th', tilte));
+ this.tableEl.querySelectorAll('tr:first-child input').forEach(() => {
+ trEl.appendChild(this._makeCell('td', null, this._randomColor()));
+ });
+ trEl.appendChild(this._makeDeleteButton('o_we_matrix_remove_row'));
+ const tbody = this.tableEl.querySelector('tbody');
+ tbody.insertBefore(trEl, tbody.lastElementChild);
+ },
+ /**
+ * @private
+ * @param {string} tag tag of the HTML Element (td/th)
+ * @param {string} value The current value of the cell input
+ * @param {string} backgroundColor The background Color of the data on the graph
+ * @param {string} borderColor The border Color of the the data on the graph
+ * @returns {HTMLElement}
+ */
+ _makeCell: function (tag, value, backgroundColor, borderColor) {
+ const newEl = document.createElement(tag);
+ const contentEl = document.createElement('input');
+ contentEl.type = 'text';
+ contentEl.value = value || '';
+ if (backgroundColor) {
+ contentEl.dataset.backgroundColor = backgroundColor;
+ }
+ if (borderColor) {
+ contentEl.dataset.borderColor = borderColor;
+ }
+ newEl.appendChild(contentEl);
+ return newEl;
+ },
+ /**
+ * Display the remove button coresponding to the colIndex
+ *
+ * @private
+ * @param {Int} colIndex Can be undefined, if so the last remove button of the column will be shown
+ */
+ _displayRemoveColButton: function (colIndex) {
+ if (this._getColumnCount() > 1) {
+ this._displayRemoveButton(colIndex, 'o_we_matrix_remove_col');
+ }
+ },
+ /**
+ * Display the remove button coresponding to the rowIndex
+ *
+ * @private
+ * @param {Int} rowIndex Can be undefined, if so the last remove button of the row will be shown
+ */
+ _displayRemoveRowButton: function (rowIndex) {
+ //Nbr of row minus header and button
+ const rowCount = this.tableEl.rows.length - 2;
+ if (rowCount > 1) {
+ this._displayRemoveButton(rowIndex, 'o_we_matrix_remove_row');
+ }
+ },
+ /**
+ * @private
+ * @param {Int} tdIndex Can be undefined, if so the last remove button will be shown
+ * @param {String} btnClass Either o_we_matrix_remove_col or o_we_matrix_remove_row
+ */
+ _displayRemoveButton: function (tdIndex, btnClass) {
+ const removeBtn = this.tableEl.querySelectorAll(`td we-button.${btnClass}`);
+ removeBtn.forEach(el => el.style.display = ''); //hide all
+ const index = tdIndex < removeBtn.length ? tdIndex : removeBtn.length - 1;
+ removeBtn[index].style.display = 'inline-block';
+ },
+ /**
+ * @private
+ * @return {boolean}
+ */
+ _isPieChart: function () {
+ return ['pie', 'doughnut'].includes(this.$target[0].dataset.type);
+ },
+ /**
+ * Return the number of column minus header and button
+ * @private
+ * @return {integer}
+ */
+ _getColumnCount: function () {
+ return this.tableEl.rows[0].cells.length - 2;
+ },
+ /**
+ * Select the first data input
+ *
+ * @private
+ */
+ _setDefaultSelectedInput: function () {
+ this.lastEditableSelectedInput = this.tableEl.querySelector('td input');
+ if (this._isPieChart()) {
+ this.colorPaletteSelectedInput = this.lastEditableSelectedInput;
+ } else {
+ this.colorPaletteSelectedInput = this.tableEl.querySelector('th input');
+ }
+ },
+ /**
+ * Return a random hexadecimal color.
+ *
+ * @private
+ * @return {string}
+ */
+ _randomColor: function () {
+ return '#' + ('00000' + (Math.random() * (1 << 24) | 0).toString(16)).slice(-6).toUpperCase();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Used by colorPalette to retrieve the custom colors used on the chart
+ * Make an array with all the custom colors used on the chart
+ * and apply it to the onSuccess method provided by the trigger_up.
+ *
+ * @private
+ */
+ _onGetCustomColors: function (ev) {
+ const data = JSON.parse(this.$target[0].dataset.data || '');
+ let customColors = [];
+ data.datasets.forEach(el => {
+ if (this._isPieChart()) {
+ customColors = customColors.concat(el.backgroundColor).concat(el.borderColor);
+ } else {
+ customColors.push(el.backgroundColor);
+ customColors.push(el.borderColor);
+ }
+ });
+ customColors = customColors.filter((el, i, array) => {
+ return !this.style.getPropertyValue(`--${el}`) && array.indexOf(el) === i && el !== ''; // unique non class not transparent
+ });
+ ev.data.onSuccess(customColors);
+ },
+ /**
+ * Add a row at the end of the matrix and display it's remove button
+ * Choose the color of the column from the theme array or a random color if they are already used
+ *
+ * @private
+ */
+ _onAddColumnClick: function () {
+ const usedColor = Array.from(this.tableEl.querySelectorAll('tr:first-child input')).map(el => el.dataset.backgroundColor);
+ const color = this.themeArray.filter(el => !usedColor.includes(el))[0] || this._randomColor();
+ this._addColumn(null, null, color, color);
+ this._reloadGraph().then(() => {
+ this._displayRemoveColButton();
+ this.updateUI();
+ });
+ },
+ /**
+ * Add a column at the end of the matrix and display it's remove button
+ *
+ * @private
+ */
+ _onAddRowClick: function () {
+ this._addRow();
+ this._reloadGraph().then(() => {
+ this._displayRemoveRowButton();
+ this.updateUI();
+ });
+ },
+ /**
+ * Remove the column and show the remove button of the next column or the last if no next.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onRemoveColumnClick: function (ev) {
+ const cell = ev.currentTarget.parentElement;
+ const cellIndex = cell.cellIndex;
+ this.tableEl.querySelectorAll('tr').forEach((el) => {
+ el.children[cellIndex].remove();
+ });
+ this._displayRemoveColButton(cellIndex - 1);
+ this._reloadGraph().then(() => {
+ this.updateUI();
+ });
+ },
+ /**
+ * Remove the row and show the remove button of the next row or the last if no next.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onRemoveRowClick: function (ev) {
+ const row = ev.currentTarget.parentElement.parentElement;
+ const rowIndex = row.rowIndex;
+ row.remove();
+ this._displayRemoveRowButton(rowIndex - 1);
+ this._reloadGraph().then(() => {
+ this.updateUI();
+ });
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onMatrixInputFocusOut: function (ev) {
+ // Sometimes, an input is focusout for internal reason (like an undo
+ // recording) then focused again manually in the same JS stack
+ // execution. In that case, the blur should not trigger an option
+ // selection as the user did not leave the input. We thus defer the blur
+ // handling to then check that the target is indeed still blurred before
+ // executing the actual option selection.
+ setTimeout(() => {
+ if (ev.currentTarget === document.activeElement) {
+ return;
+ }
+ this._reloadGraph();
+ });
+ },
+ /**
+ * Set the selected cell/header and display the related remove button
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMatrixInputFocus: function (ev) {
+ this.lastEditableSelectedInput = ev.target;
+ const col = ev.target.parentElement.cellIndex;
+ const row = ev.target.parentElement.parentElement.rowIndex;
+ if (this._isPieChart()) {
+ this.colorPaletteSelectedInput = ev.target.parentNode.tagName === 'TD' ? ev.target : null;
+ } else {
+ this.colorPaletteSelectedInput = this.tableEl.querySelector(`tr:first-child th:nth-of-type(${col + 1}) input`);
+ }
+ if (col > 0) {
+ this._displayRemoveColButton(col - 1);
+ }
+ if (row > 0) {
+ this._displayRemoveRowButton(row - 1);
+ }
+ this.updateUI();
+ },
+});
+
+const InputUserValueWidget = options.userValueWidgetsRegistry['we-input'];
+const UrlPickerUserValueWidget = InputUserValueWidget.extend({
+ custom_events: _.extend({}, InputUserValueWidget.prototype.custom_events || {}, {
+ website_url_chosen: '_onWebsiteURLChosen',
+ }),
+
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+ $(this.inputEl).addClass('text-left');
+ wUtils.autocompleteWithPages(this, $(this.inputEl));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the autocomplete change the input value.
+ *
+ * @private
+ */
+ _onWebsiteURLChosen: function () {
+ $(this.inputEl).trigger('input');
+ this._notifyValueChange(false);
+ },
+});
+
+options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget;
+return {
+ UrlPickerUserValueWidget: UrlPickerUserValueWidget,
+};
+});
diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss
new file mode 100644
index 00000000000..4a9729fb1f8
--- /dev/null
+++ b/addons/website/static/src/scss/website.scss
@@ -0,0 +1,921 @@
+///
+/// This file regroups the website design rules.
+///
+
+$-seen: ();
+$-font-numbers: (
+ o-website-value('font-number'),
+ o-website-value('headings-font-number'),
+ o-website-value('navbar-font-number'),
+ o-website-value('buttons-font-number'),
+);
+@each $-number in $-font-numbers {
+ @if index($-seen, $-number) == null {
+ $-seen: append($-seen, $-number);
+ $-theme-font: nth($o-theme-font-urls, $-number);
+ @if $-theme-font {
+ @import url("https://fonts.googleapis.com/css?family=#{unquote($-theme-font)}&display=swap");
+ }
+ }
+}
+
+:root {
+ // The theme customize modal JS will need to know the value of some scss
+ // variables used to render the user website, and those may have been
+ // customized by themes, the user or anything else (so there is no file to
+ // parse to get them). Those will be printed here as CSS variables.
+
+ // 1) Handle default values
+ --header-font-size: #{$font-size-base};
+
+ // 2) The values in the $theme-colors map are already printed by Bootstrap.
+
+ // 3) The values in the $colors map are also printed by Bootstrap.
+
+ // 4) The Odoo values map, $o-website-values, must be printed.
+ @each $key, $value in $o-website-values {
+ --#{$key}: #{$value};
+ }
+
+ // 5) Use final value used by the theme
+ $-h1-color: if($headings-color == inherit or not $headings-color, $body-color, $headings-color);
+ $-h2-color: if(color('h2'), color('h2'), $-h1-color);
+ $-h3-color: if(color('h3'), color('h3'), $-h2-color);
+ $-h4-color: if(color('h4'), color('h4'), $-h3-color);
+ $-h5-color: if(color('h5'), color('h5'), $-h4-color);
+ $-h6-color: if(color('h6'), color('h6'), $-h5-color);
+
+ --body: #{$body-bg};
+ --text: #{$body-color};
+ --h1: #{$-h1-color};
+ --h2: #{$-h2-color};
+ --h3: #{$-h3-color};
+ --h4: #{$-h4-color};
+ --h5: #{$-h5-color};
+ --h6: #{$-h6-color};
+
+ --logo-height: #{$o-theme-navbar-logo-height};
+ --number-of-fonts: #{length($o-theme-fonts)};
+}
+
+#wrapwrap {
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-attachment: fixed;
+
+ @if ($o-theme-layout == 'boxed') {
+ @include media-breakpoint-up(md) {
+ padding: 0 7.5%;
+ }
+
+ #wrap {
+ background-color: $white;
+ }
+ }
+}
+
+.navbar {
+ @if ($o-theme-navbar-font != $o-theme-font) {
+ font-family: $o-theme-navbar-font;
+ }
+
+ .nav-item {
+ transition: opacity 1000ms ease 0s;
+ }
+ .o_menu_loading .nav-item {
+ height: 0 !important;
+ overflow: hidden !important;
+ opacity: 0 !important;
+ }
+}
+
+.navbar-light {
+ // Style only navbar-light which Odoo is only supposed to use in standard
+ // anyway. Automatically mimic navbar-dark if the user's menu color is dark.
+ // Note: this only works because navbar-light is defined before navbar-dark,
+ // we may want to use a safest way when possible.
+ @if color('menu') {
+ background-color: color('menu') !important;
+ @if (color-yiq(color('menu')) != $yiq-text-dark) {
+ @extend .navbar-dark;
+ }
+ }
+}
+
+$-header-nav-link-height: $nav-link-height;
+@if o-website-value('header-font-size') {
+ $-header-nav-link-height: o-website-value('header-font-size') * $line-height-base + $nav-link-padding-y * 2;
+ header {
+ font-size: o-website-value('header-font-size');
+
+ .dropdown-menu {
+ font-size: inherit;
+ }
+ }
+}
+@if $o-theme-navbar-logo-height {
+ // With default values, this makes it slightly bigger than standard
+ // navbar-brand, which is what we want
+ header .navbar-brand {
+ font-size: $o-theme-navbar-logo-height / $line-height-base;
+
+ $-logo-padding-y: max(0, $-header-nav-link-height - $o-theme-navbar-logo-height) / 2;
+ &, &.logo {
+ padding-top: $-logo-padding-y;
+ padding-bottom: $-logo-padding-y;
+ }
+ }
+}
+
+.o_footer {
+ @if color('footer') {
+ @include o-bg-color(color('footer'));
+ }
+
+ > #footer {
+ // FIXME needed to prevent the dropzone at the bottom of the #wrap element
+ // to be at the same place of the dropzone at the top of the #footer element
+ border-top: 1px solid transparent;
+ }
+}
+h2, h3, h4, h5, h6 {
+ color: color('h2');
+}
+h3, h4, h5, h6 {
+ color: color('h3');
+}
+h4, h5, h6 {
+ color: color('h4');
+}
+h5, h6 {
+ color: color('h5');
+}
+h6 {
+ color: color('h6');
+}
+.btn {
+ @if ($o-theme-buttons-font != $o-theme-font) {
+ font-family: $o-theme-buttons-font;
+ }
+}
+
+// Texts
+font[style*='background'],
+font[class*='bg-'] {
+ padding: 2px 6px 4px;
+}
+
+// Icons
+.fa {
+ font-family: "FontAwesome" !important;
+ $size: 3rem;
+
+ &.rounded-circle,
+ &.rounded,
+ &.rounded-0,
+ &.rounded-leaf,
+ &.img-thumbnail,
+ &.shadow {
+ display: inline-block;
+ vertical-align: middle;
+ text-align: center;
+ // fa-1x is not ouput
+ @include size($size);
+ line-height: $size;
+ @for $i from 2 through 5 {
+ &.fa-#{$i}x {
+ @include size($size + $i);
+ line-height: $size + $i;
+ }
+ }
+ // Default, if no background-color already selected
+ background-color: $gray-100;
+ }
+ &.img-thumbnail {
+ padding: 0;
+ }
+ &.rounded-leaf {
+ border-top-left-radius: $size;
+ border-bottom-right-radius: $size;
+ }
+}
+
+// Buttons
+.btn {
+ &.flat {
+ border: 0;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ @include button-size(0.75rem, 1.5rem, ($font-size-base * .75), $btn-line-height, 0);
+ &.btn-lg { @include button-size(1rem, 2rem, ($font-size-lg * .75), $btn-line-height-lg, 0); }
+ &.btn-sm { @include button-size(.5rem, 1rem, ($font-size-sm * .75), $btn-line-height-sm, 0); }
+ &.btn-xs { @include button-size(.25rem, .5rem, ($font-size-base * .5), $btn-line-height-sm, 0); }
+ }
+ &.rounded-circle {
+ border-radius: 100px !important;
+ @include button-size(0.45rem, 1.35rem, $font-size-base, $btn-line-height, 30px);
+ &.btn-lg { @include button-size(.6rem, 1.8rem, $font-size-lg, $btn-line-height-lg, 30px); }
+ &.btn-sm { @include button-size(.3rem, .9rem, $font-size-sm, $btn-line-height-sm, 30px); }
+ &.btn-xs { @include button-size(.15rem, .45rem, ($font-size-base * .75), $btn-line-height-sm, 30px); }
+ }
+}
+
+// Blockquotes
+.blockquote {
+ font-size: 1rem;
+ footer {
+ background-color: inherit;
+ }
+}
+
+// Background Images
+.oe_img_bg {
+ background-size: cover;
+ background-repeat: no-repeat;
+
+ &.o_bg_img_opt_repeat {
+ background-size: auto;
+ background-repeat: repeat;
+ }
+
+ // Compatibility <= 13.0, TODO remove?
+ // -----------------------------------
+ &.o_bg_img_opt_contain {
+ background-size: contain;
+ background-position: center center;
+ }
+ &.o_bg_img_opt_custom {
+ background-size: auto;
+ }
+ &.o_bg_img_opt_repeat_x {
+ background-repeat: repeat-x;
+ }
+ &.o_bg_img_opt_repeat_y {
+ background-repeat: repeat-y;
+ }
+}
+
+// Background videos
+.o_background_video {
+ position: relative;
+
+ > * {
+ position: relative;
+ }
+}
+.o_bg_video_container {
+ @include o-position-absolute(0, 0 ,0 ,0);
+ overflow: hidden;
+}
+.o_bg_video_iframe {
+ position: relative;
+ pointer-events: none !important;
+}
+.o_bg_video_loading {
+ @include o-position-absolute(0, 0 ,0 ,0);
+}
+
+// Probably outdated
+// Lists
+.o_ul_toggle {
+ display: none;
+}
+
+.o_ul_folded {
+ .o_close {
+ display: none!important;
+ }
+ .o_ul_toggle_self,
+ .o_ul_toggle_next {
+ display: inline-block;
+ line-height: inherit;
+ float: left;
+ position: relative;
+ margin-left: -1em;
+ top: -0.15em;
+ left: 0.2em;
+ font-size: 1.4em;
+ text-decoration: none;
+ &.o_open:before {
+ content: "";
+ }
+ &:before {
+ content: "";
+ }
+ }
+}
+
+// Probably outdated
+// Disable fixed height
+@media (max-width: 400px) {
+ section,
+ .parallax,
+ .row,
+ .hr,
+ .blockquote {
+ height: auto !important;
+ }
+}
+
+// Probably outdated
+// Table
+.table_desc {
+ margin: 0 0 20px 0;
+ width: 100%;
+ word-break: break-all;
+ border: 1px solid #dddddd;
+}
+.table_heading {
+ background-color: #f5f5f5;
+ border: 1px solid #dddddd;
+ color: #666666;
+ font-size: 14px;
+ padding: 4px;
+}
+table.table_desc tr td {
+ text-align: left;
+ padding: 5px;
+ font-size: 13px;
+ &:first-child {
+ width: 25%;
+ font-weight: bold;
+ border-bottom: 1px solid #c9c9c9;
+ border-right: 1px solid #c9c9c9;
+ border-left: none;
+ }
+ &:last-child {
+ border-bottom: 1px solid #c9c9c9;
+ }
+}
+
+// Jumbotron
+.jumbotron {
+ border-radius: 0;
+}
+
+//
+// Snippets
+//
+
+// Title
+.s_title {
+ .s_title_boxed {
+ > * {
+ display: inline-block;
+ padding: $grid-gutter-width;
+ border: 1px solid;
+ }
+ }
+ .s_title_lines {
+ overflow: hidden;
+ &:before,
+ &:after {
+ content: "";
+ display: inline-block;
+ vertical-align: middle;
+ width: 100%;
+ border-top: 1px solid;
+ border-top-color: inherit;
+ }
+ &:before { margin: 0 $grid-gutter-width/2 0 -100%; }
+ &:after { margin: 0 -100% 0 $grid-gutter-width/2; }
+ }
+ .s_title_underlined {
+ @extend %o-page-header;
+ }
+ .s_title_small_caps {
+ font-variant: small-caps;
+ }
+ .s_title_transparent {
+ opacity: .5;
+ }
+ .s_title_thin {
+ font-weight: 300;
+ }
+}
+
+
+// Features Grid
+.s_features_grid {
+ &_content {
+ overflow: hidden;
+ p {
+ margin-bottom: 0;
+ }
+ }
+ &_icon {
+ float: left;
+ margin-right: $grid-gutter-width/2;
+ }
+}
+
+
+// Comparisons
+.s_comparisons {
+ .card-body {
+ .card-title {
+ margin: 0;
+ }
+ .s_comparisons_currency,
+ .s_comparisons_price,
+ .s_comparisons_decimal {
+ display: inline-block;
+ vertical-align: middle;
+ }
+ .s_comparisons_currency,
+ .s_comparisons_decimal {
+ font-size: 80%;
+ }
+ .s_comparisons_price {
+ font-size: 200%;
+ }
+ }
+}
+
+
+// FAQ
+.s_faq_collapse {
+ .accordion .card {
+ .card-header {
+ cursor: pointer;
+ display: inline-block;
+ width: 100%;
+ padding: .5em 0;
+ border-radius: 0;
+ outline: none;
+ &:before {
+ content:'\f056';
+ font-family: 'FontAwesome';
+ display: inline-block;
+ margin: 0 .5em 0 .75em;
+ color: $gray-600;
+ }
+ &.collapsed:before {
+ content:'\f055';
+ font-family: 'FontAwesome';
+ }
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
+ }
+ .card-body {
+ padding: 1em 2.25em;
+ }
+ }
+ .card-body p:last-child,
+ .card-body ul:last-child {
+ margin-bottom: 0;
+ }
+}
+
+
+// References
+.s_references .img-thumbnail {
+ border: none;
+}
+
+// Carousel -> TODO: should be versioned in 000.scss file but how ?
+.s_carousel,
+.s_quotes_carousel {
+
+ // Controls
+ .carousel-control-prev,
+ .carousel-control-next {
+ cursor: pointer;
+ width: 8%;
+ opacity: 1;
+ }
+ .carousel-control-prev { justify-content: flex-start; }
+ .carousel-control-next { justify-content: flex-end; }
+ .carousel-control-prev-icon,
+ .carousel-control-next-icon {
+ @include size(auto);
+ background-image: none;
+ color: $body-color;
+ &:before {
+ font-family: "FontAwesome";
+ display: inline-block;
+ background-color: #fff;
+ }
+ }
+ // Content
+ .carousel-inner {
+ overflow: hidden;
+ height: 100%;
+ .carousel-item {
+ height: 100%;
+ }
+ }
+ // Indicators
+ .carousel-indicators li:hover:not(.active) {
+ background-color: rgba(255,255,255,.8);
+ }
+
+ // Default
+ &.s_carousel_default {
+ // Controls - chevron
+ .carousel-control-prev-icon:before { content: "\f053" #{"/*rtl:'\f054'*/"}; margin-left: 1.5rem; }
+ .carousel-control-next-icon:before { content: "\f054" #{"/*rtl:'\f053'*/"}; margin-right: 1.5rem; }
+ .carousel-control-prev-icon:before,
+ .carousel-control-next-icon:before {
+ background-color: rgba(0,0,0,0);
+ font-size: 2rem;
+ color: #fff;
+ text-shadow: $box-shadow-sm;
+ }
+ // Indicators
+ .carousel-indicators li {
+ height: .5rem;
+ box-shadow: $box-shadow-sm;
+ border-radius: 2px;
+ }
+ }
+
+ // Border
+ &.s_carousel_bordered {
+ border: 2rem solid rgba(0,0,0,0);
+ // Controls - caret
+ .carousel-control-prev-icon:before { content: "\f0d9"; }
+ .carousel-control-next-icon:before { content: "\f0da"; }
+ .carousel-control-prev-icon:before,
+ .carousel-control-next-icon:before {
+ @include size(2rem, 6rem);
+ line-height: 6rem;
+ font-size: 1.5rem;
+ }
+ // Indicators
+ .carousel-indicators li {
+ @include size(3rem, 1rem);
+ }
+ }
+
+ // Circle
+ &.s_carousel_rounded {
+ // Container
+ // .carousel-inner {
+ // border-top-left-radius: 10rem;
+ // border-bottom-right-radius: 10rem;
+ // }
+ // Controls - arrow
+ .carousel-control-prev { margin-left: 1.5rem; }
+ .carousel-control-next { margin-right: 1.5rem; }
+ .carousel-control-prev-icon:before { content: "\f060"; }
+ .carousel-control-next-icon:before { content: "\f061"; }
+ .carousel-control-prev-icon:before,
+ .carousel-control-next-icon:before {
+ @include size(4rem);
+ line-height: 4rem;
+ border-radius: 50%;
+ font-size: 1.25rem;
+ }
+ // Indicators
+ .carousel-indicators li {
+ @include size(1rem);
+ border-radius: 50%;
+ }
+ }
+
+ // Boxed
+ &.s_carousel_boxed {
+ @include make-container();
+ @include make-container-max-widths();
+ .carousel-item {
+ padding: 0 1rem;
+ }
+ // Controls - angle
+ .carousel-control-prev,
+ .carousel-control-next {
+ align-items: flex-end;
+ margin-bottom: 1.25rem;
+ }
+ .carousel-control-prev { margin-left: 3rem; }
+ .carousel-control-next { margin-right: 3rem; }
+ .carousel-control-prev-icon:before { content: "\f104"; }
+ .carousel-control-next-icon:before { content: "\f105"; }
+ .carousel-control-prev-icon:before,
+ .carousel-control-next-icon:before {
+ @include size(2rem);
+ line-height: 2rem;
+ font-size: 1.25rem;
+ }
+ // Indicators
+ .carousel-indicators li {
+ @include size(1rem);
+ &:hover:not(.active) {
+ background-color: rgba(255,255,255,.8);
+ }
+ }
+ }
+}
+
+
+.carousel .container {
+ .carousel-img img {
+ max-height: 95%;
+ padding: 10px;
+ }
+ > .carousel-caption {
+ @include o-position-absolute($right: 50%, $left: 50%);
+ bottom: 20px;
+ > div {
+ position: absolute;
+ text-align: left;
+ padding: 20px;
+ background: rgba(0, 0, 0, 0.4);
+ bottom: 20px;
+ }
+ }
+ > .carousel-image {
+ @include o-position-absolute($top: 5%, $bottom: 5%);
+ max-height: 90%;
+ margin: 0 auto;
+ }
+ .carousel-item.text_image .container {
+ > .carousel-caption {
+ left: 10%;
+ > div {
+ right: 50%;
+ margin-right: -20%;
+ max-width: 550px;
+ }
+ }
+ > .carousel-image {
+ right: 10%;
+ left: 50%;
+ }
+ }
+ .carousel-item.image_text .container {
+ > .carousel-caption {
+ right: 10%;
+ > div {
+ left: 50%;
+ margin-left: -20%;
+ max-width: 550px;
+ }
+ }
+ > .carousel-image {
+ right: 50%;
+ left: 10%;
+ }
+ }
+ .carousel-item.text_only .container {
+ > .carousel-caption {
+ left: 10%;
+ right: 10%;
+ top: 10%;
+ bottom: auto;
+ > div {
+ text-align: center;
+ background: transparent;
+ bottom: auto;
+ width: 100%;
+ }
+ }
+ > .carousel-image {
+ display: none !important;
+ }
+ }
+}
+
+
+// Quotes Slider
+.s_quotes_carousel {
+ blockquote {
+ padding: $grid-gutter-width;
+ margin-bottom: 0;
+ .s_quotes_carousel_icon {
+ position: absolute;
+ top: 0;
+ left: -3rem;
+ }
+ img {
+ max-width: 40px;
+ margin-right: 5px;
+ border-radius: 50%;
+ }
+ footer {
+ background-color: transparent;
+ &:before {
+ content:"";
+ }
+ }
+ }
+}
+
+// Company Team
+.s_company_team {
+ @include media-breakpoint-down(md) {
+ img {
+ max-width: 50%;
+ }
+ }
+}
+
+// Parallax
+.parallax {
+ position: relative;
+
+ // TODO this introduces a limitation: no dropdown will be able to
+ // overflow. Maybe there is a better way to find.
+ &:not(.s_parallax_no_overflow_hidden) {
+ overflow: hidden;
+ }
+
+ > * {
+ position: relative;
+ }
+ > .s_parallax_bg {
+ @include o-position-absolute(0, 0, 0, 0);
+ display: block;
+ background-color: inherit;
+ background-size: cover;
+ background-attachment: scroll;
+ pointer-events: none;
+
+ &::after {
+ content: "";
+ @include o-position-absolute(0, 0, 0, 0);
+ display: block;
+ background-color: inherit;
+ }
+ }
+ @include media-breakpoint-up(xl) {
+ // Fixed backgrounds are disabled when using a mobile/tablet device,
+ // which is not a big deal but, on some of them (iOS...), defining the
+ // background as fixed breaks the background-size/position props.
+ // So we enable this only for >= XL devices.
+ &.s_parallax_is_fixed > .s_parallax_bg {
+ background-attachment: fixed;
+ }
+ }
+}
+
+
+//
+// Layout
+//
+
+// Header
+.o_header_affix {
+ display: block;
+ @include o-position-absolute(0, 0, auto, 0);
+ position: fixed;
+ z-index: $zindex-modal - 20;
+ background: $light;
+ margin-top: -999px;
+ transition: margin-top 500ms ease 0s;
+ @include media-breakpoint-down(sm) {
+ .navbar-collapse {
+ max-height: 70vh;
+ overflow-y: auto;
+ }
+ }
+ &.affixed {
+ margin-top: 0px !important; // the default margin-top is adapted in JS
+ }
+}
+#oe_main_menu_navbar + #wrapwrap .o_header_affix {
+ top: $o-navbar-height;
+}
+
+// Navbar
+.navbar .o_extra_menu_items.show > ul {
+ > li {
+ + li {
+ border-top: 1px solid gray('200');
+ }
+ > a.dropdown-toggle {
+ background-color: gray('200');
+ color: inherit; // Useful when the toggle is active
+ pointer-events: none; // hack to prevent clicking on it because dropdown always opened
+ }
+ > ul { // remove dropdown-menu default style as it is nested in another one
+ position: static;
+ float: none;
+ display: block;
+ max-height: none;
+ margin-top: 0;
+ padding: 0;
+ border: none;
+ box-shadow: none;
+ }
+ }
+}
+
+// Mega menu
+.o_mega_menu {
+ width: 100%;
+ padding: 0;
+ margin-top: 0;
+ border-radius: 0;
+ background-clip: unset; // Remove the 1px gap introduced by BS4
+
+ .container, .container-fluid {
+ // Need to reforce those because they are removed since its a container
+ // inside another container (the one in the navbar)
+ padding-left: $grid-gutter-width / 2;
+ padding-right: $grid-gutter-width / 2;
+ }
+}
+.o_mega_menu_container_size {
+ @include media-breakpoint-up(md) {
+ left: 50%;
+ transform: translateX(-50%);
+ }
+
+ $-mm-max-widths: ();
+ @each $k, $v in $container-max-widths {
+ $-mm-max-widths: map-merge($-mm-max-widths, (
+ #{$k}: $v - $grid-gutter-width,
+ ));
+ }
+ @include make-container-max-widths($-mm-max-widths);
+}
+
+// Copyright
+.o_footer_copyright {
+ padding: 8px;
+ .js_language_selector {
+ display: inline-block;
+ }
+ @include media-breakpoint-up(md) {
+ .row {
+ display: flex;
+ > div {
+ margin: auto 0;
+ }
+ }
+ }
+}
+
+#wrapwrap.o_header_overlay {
+ > header:not(.o_header_affix) {
+ @include o-position-absolute(0, 0, auto, 0);
+ z-index: 1000;
+
+ > .navbar {
+ background-color: transparent !important;
+ border-color: transparent;
+
+ .nav-item {
+ > .nav-link {
+ background-color: transparent;
+ color: inherit;
+ }
+ &.show, &.active {
+ > .nav-link {
+ background-color: white;
+ color: black;
+ }
+ }
+ }
+ }
+ }
+}
+// Figure with special style
+.o_figure_relative_layout {
+ position: relative;
+
+ .figure-img {
+ margin-bottom: 0;
+ }
+ .figure-caption {
+ @include o-position-absolute(auto, 0, 0, 0);
+ @include o-bg-color(rgba(theme-color('dark'), $o-theme-figcaption-opacity));
+ padding: $tooltip-padding-y $tooltip-padding-x;
+ font-weight: $font-weight-bold;
+ a {
+ color: inherit;
+ }
+ }
+}
+
+@each $color, $value in $theme-colors {
+ .bg-#{$color}-light {
+ background-color: rgba($value, 0.1);
+ }
+}
+
+@each $media, $color in $o-social-colors {
+ @include text-emphasis-variant(".text-#{$media}", $color);
+}
+
+// TODO: Will be handled properly in master/saas-12.2, temp fix for website_event.registration_attendee_details
+.modal-footer > .float-left {
+ margin-right: auto;
+}
+
+// CoverProperties
+.o_record_cover_container {
+ position: relative;
+
+ .o_record_cover_component {
+ @include o-position-absolute(0, 0, 0, 0);
+
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_image_gallery/000.js b/addons/website/static/src/snippets/s_image_gallery/000.js
new file mode 100644
index 00000000000..72a9f2a0fe1
--- /dev/null
+++ b/addons/website/static/src/snippets/s_image_gallery/000.js
@@ -0,0 +1,172 @@
+odoo.define('website.s_image_gallery', function (require) {
+'use strict';
+
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+
+var qweb = core.qweb;
+
+const GalleryWidget = publicWidget.Widget.extend({
+ selector: '.o_gallery:not(.o_slideshow)',
+ xmlDependencies: ['/website/static/src/xml/website.gallery.xml'],
+ events: {
+ 'click img': '_onClickImg',
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when an image is clicked. Opens a dialog to browse all the images
+ * with a bigger size.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickImg: function (ev) {
+ var self = this;
+ var $cur = $(ev.currentTarget);
+
+ var urls = [];
+ var idx = undefined;
+ var milliseconds = undefined;
+ var params = undefined;
+ var $images = $cur.closest('.o_gallery').find('img');
+ var size = 0.8;
+ var dimensions = {
+ min_width: Math.round(window.innerWidth * size * 0.9),
+ min_height: Math.round(window.innerHeight * size),
+ max_width: Math.round(window.innerWidth * size * 0.9),
+ max_height: Math.round(window.innerHeight * size),
+ width: Math.round(window.innerWidth * size * 0.9),
+ height: Math.round(window.innerHeight * size)
+ };
+
+ $images.each(function () {
+ urls.push($(this).attr('src'));
+ });
+ var $img = ($cur.is('img') === true) ? $cur : $cur.closest('img');
+ idx = urls.indexOf($img.attr('src'));
+
+ milliseconds = $cur.closest('.o_gallery').data('interval') || false;
+ var $modal = $(qweb.render('website.gallery.slideshow.lightbox', {
+ srcs: urls,
+ index: idx,
+ dim: dimensions,
+ interval: milliseconds || 0,
+ id: _.uniqueId('slideshow_'),
+ }));
+ $modal.modal({
+ keyboard: true,
+ backdrop: true,
+ });
+ $modal.on('hidden.bs.modal', function () {
+ $(this).hide();
+ $(this).siblings().filter('.modal-backdrop').remove(); // bootstrap leaves a modal-backdrop
+ $(this).remove();
+ });
+ $modal.find('.modal-content, .modal-body.o_slideshow').css('height', '100%');
+ $modal.appendTo(document.body);
+
+ $modal.one('shown.bs.modal', function () {
+ self.trigger_up('widgets_start_request', {
+ editableMode: false,
+ $target: $modal.find('.modal-body.o_slideshow'),
+ });
+ });
+ },
+});
+
+const GallerySliderWidget = publicWidget.Widget.extend({
+ selector: '.o_slideshow',
+ xmlDependencies: ['/website/static/src/xml/website.gallery.xml'],
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$carousel = this.$target.is('.carousel') ? this.$target : this.$target.find('.carousel');
+ this.$indicator = this.$carousel.find('.carousel-indicators');
+ this.$prev = this.$indicator.find('li.o_indicators_left').css('visibility', ''); // force visibility as some databases have it hidden
+ this.$next = this.$indicator.find('li.o_indicators_right').css('visibility', '');
+ var $lis = this.$indicator.find('li[data-slide-to]');
+ var nbPerPage = Math.floor(this.$indicator.width() / $lis.first().outerWidth(true)) - 3; // - navigator - 1 to leave some space
+ var realNbPerPage = nbPerPage || 1;
+ var nbPages = Math.ceil($lis.length / realNbPerPage);
+
+ var index;
+ var page;
+ update();
+
+ function hide() {
+ $lis.each(function (i) {
+ $(this).toggleClass('d-none', i < page * nbPerPage || i >= (page + 1) * nbPerPage);
+ });
+ if (self.editableMode) { // do not remove DOM in edit mode
+ return;
+ }
+ if (page <= 0) {
+ self.$prev.detach();
+ } else {
+ self.$prev.prependTo(self.$indicator);
+ }
+ if (page >= nbPages - 1) {
+ self.$next.detach();
+ } else {
+ self.$next.appendTo(self.$indicator);
+ }
+ }
+
+ function update() {
+ index = $lis.index($lis.filter('.active')) || 0;
+ page = Math.floor(index / realNbPerPage);
+ hide();
+ }
+
+ this.$carousel.on('slide.bs.carousel.gallery_slider', function () {
+ setTimeout(function () {
+ var $item = self.$carousel.find('.carousel-inner .carousel-item-prev, .carousel-inner .carousel-item-next');
+ var index = $item.index();
+ $lis.removeClass('active')
+ .filter('[data-slide-to="' + index + '"]')
+ .addClass('active');
+ }, 0);
+ });
+ this.$indicator.on('click.gallery_slider', '> li:not([data-slide-to])', function () {
+ page += ($(this).hasClass('o_indicators_left') ? -1 : 1);
+ page = Math.max(0, Math.min(nbPages - 1, page)); // should not be necessary
+ self.$carousel.carousel(page * realNbPerPage);
+ hide();
+ });
+ this.$carousel.on('slid.bs.carousel.gallery_slider', update);
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+
+ if (!this.$indicator) {
+ return;
+ }
+
+ this.$prev.prependTo(this.$indicator);
+ this.$next.appendTo(this.$indicator);
+ this.$carousel.off('.gallery_slider');
+ this.$indicator.off('.gallery_slider');
+ },
+});
+
+publicWidget.registry.gallery = GalleryWidget;
+publicWidget.registry.gallerySlider = GallerySliderWidget;
+
+return {
+ GalleryWidget: GalleryWidget,
+ GallerySliderWidget: GallerySliderWidget,
+};
+});
diff --git a/addons/website/static/src/snippets/s_image_gallery/000.scss b/addons/website/static/src/snippets/s_image_gallery/000.scss
new file mode 100644
index 00000000000..dce08072f5d
--- /dev/null
+++ b/addons/website/static/src/snippets/s_image_gallery/000.scss
@@ -0,0 +1,156 @@
+
+.o_gallery {
+ &.o_grid, &.o_masonry {
+ .img {
+ width: 100%;
+ }
+ }
+ &.o_grid {
+ &.o_spc-none div.row {
+ margin: 0;
+ > div {
+ padding: 0;
+ }
+ }
+ &.o_spc-small div.row {
+ margin: 5px 0;
+ > div {
+ padding: 0 5px;
+ }
+ }
+ &.o_spc-medium div.row {
+ margin: 10px 0;
+ > div {
+ padding: 0 10px;
+ }
+ }
+ &.o_spc-big div.row {
+ margin: 15px 0;
+ > div {
+ padding: 0 15px;
+ }
+ }
+ &.size-auto .row {
+ height: auto;
+ }
+ &.size-small .row {
+ height: 100px;
+ }
+ &.size-medium .row {
+ height: 250px;
+ }
+ &.size-big .row {
+ height: 400px;
+ }
+ &.size-small, &.size-medium, &.size-big {
+ img {
+ height: 100%;
+ }
+ }
+ }
+ &.o_masonry {
+ &.o_spc-none div.col {
+ padding: 0;
+ > img {
+ margin: 0 !important;
+ }
+ }
+ &.o_spc-small div.col {
+ padding: 0 5px;
+ > img {
+ margin: 5px 0 !important;
+ }
+ }
+ &.o_spc-medium div.col {
+ padding: 0 10px;
+ > img {
+ margin: 10px 0 !important;
+ }
+ }
+ &.o_spc-big div.col {
+ padding: 0 15px;
+ > img {
+ margin: 15px 0 !important;
+ }
+ }
+ }
+ &.o_nomode {
+ &.o_spc-none .img {
+ padding: 0;
+ }
+ &.o_spc-small .img {
+ padding: 5px;
+ }
+ &.o_spc-medium .img {
+ padding: 10px;
+ }
+ &.o_spc-big .img {
+ padding: 15px;
+ }
+ }
+ &.o_slideshow {
+ .carousel ul.carousel-indicators li {
+ border: 1px solid #aaa;
+ }
+ > .container {
+ height: 100%;
+ }
+ }
+ .carousel-inner .item img {
+ max-width: none;
+ }
+}
+.o_gallery,
+.modal-body {
+ &.o_slideshow {
+ .carousel {
+ height: 100%;
+
+ .carousel-inner {
+ height: 100%;
+ }
+ .carousel-item.active,
+ .carousel-item-next,
+ .carousel-item-prev {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ padding-bottom: 64px;
+ }
+ img {
+ max-height: 100%;
+ max-width: 100%;
+ margin: auto;
+ }
+ ul.carousel-indicators {
+ height: auto;
+ padding: 0;
+ border-width: 0;
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ margin-left: 0;
+ left: 0%;
+ > * {
+ list-style-image: none;
+ display: inline-block;
+ width: 40px;
+ height: 40px;
+ margin: 2.5px 2.5px 2.5px 2.5px;
+ padding: 0;
+ border: 1px solid #aaa;
+ text-indent: initial;
+ background-size: cover;
+ background-color: #fff;
+ border-radius: 0;
+ vertical-align: bottom;
+ flex: 0 0 40px;
+ &:not(.active) {
+ opacity: 0.8;
+ filter: grayscale(1);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/website/static/src/snippets/s_image_gallery/options.js b/addons/website/static/src/snippets/s_image_gallery/options.js
new file mode 100644
index 00000000000..819ac1aedc0
--- /dev/null
+++ b/addons/website/static/src/snippets/s_image_gallery/options.js
@@ -0,0 +1,456 @@
+odoo.define('website.s_image_gallery_options', function (require) {
+'use strict';
+
+var core = require('web.core');
+var weWidgets = require('wysiwyg.widgets');
+var options = require('web_editor.snippets.options');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+options.registry.gallery = options.Class.extend({
+ xmlDependencies: ['/website/static/src/xml/website.gallery.xml'],
+
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ // The snippet should not be editable
+ this.$target.addClass('o_fake_not_editable').attr('contentEditable', false);
+
+ // Make sure image previews are updated if images are changed
+ this.$target.on('save', 'img', function (ev) {
+ var $img = $(ev.currentTarget);
+ var index = self.$target.find('.carousel-item.active').index();
+ self.$('.carousel:first li[data-target]:eq(' + index + ')')
+ .css('background-image', 'url(' + $img.attr('src') + ')');
+ });
+
+ // When the snippet is empty, an edition button is the default content
+ // TODO find a nicer way to do that to have editor style
+ this.$target.on('click', '.o_add_images', function (e) {
+ e.stopImmediatePropagation();
+ self.addImages(false);
+ });
+
+ this.$target.on('dropped', 'img', function (ev) {
+ self.mode(null, self.getMode());
+ if (!ev.target.height) {
+ $(ev.target).one('load', function () {
+ setTimeout(function () {
+ self.trigger_up('cover_update');
+ });
+ });
+ }
+ });
+
+ if (this.$('.container:first > *:not(div)').length) {
+ self.mode(null, self.getMode());
+ }
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ var uuid = new Date().getTime();
+ this.$target.find('.carousel').attr('id', 'slideshow_' + uuid);
+ this.$target.find('[data-target]').attr('data-target', '#slideshow_' + uuid);
+ },
+ /**
+ * @override
+ */
+ cleanForSave: function () {
+ if (this.$target.hasClass('slideshow')) {
+ this.$target.removeAttr('style');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Allows to select images to add as part of the snippet.
+ *
+ * @see this.selectClass for parameters
+ */
+ addImages: function (previewMode) {
+ var $container = this.$('.container:first');
+ var dialog = new weWidgets.MediaDialog(this, {multiImages: true, onlyImages: true, mediaWidth: 1920});
+ var lastImage = _.last(this._getImages());
+ var index = lastImage ? this._getIndex(lastImage) : -1;
+ return new Promise(resolve => {
+ dialog.on('save', this, function (attachments) {
+ for (var i = 0; i < attachments.length; i++) {
+ $('<img/>', {
+ class: 'img img-fluid',
+ src: attachments[i].image_src,
+ 'data-index': ++index,
+ }).appendTo($container);
+ }
+ this.mode('reset', this.getMode());
+ this.trigger_up('cover_update');
+ resolve();
+ });
+ dialog.open();
+ });
+ },
+ /**
+ * Allows to change the number of columns when displaying images with a
+ * grid-like layout.
+ *
+ * @see this.selectClass for parameters
+ */
+ columns: function (previewMode, widgetValue, params) {
+ const nbColumns = parseInt(widgetValue || '1');
+ this.$target.attr('data-columns', nbColumns);
+
+ this.mode(previewMode, this.getMode(), {}); // TODO improve
+ },
+ /**
+ * Get the image target's layout mode (slideshow, masonry, grid or nomode).
+ *
+ * @returns {String('slideshow'|'masonry'|'grid'|'nomode')}
+ */
+ getMode: function () {
+ var mode = 'slideshow';
+ if (this.$target.hasClass('o_masonry')) {
+ mode = 'masonry';
+ }
+ if (this.$target.hasClass('o_grid')) {
+ mode = 'grid';
+ }
+ if (this.$target.hasClass('o_nomode')) {
+ mode = 'nomode';
+ }
+ return mode;
+ },
+ /**
+ * Displays the images with the "grid" layout.
+ */
+ grid: function () {
+ var imgs = this._getImages();
+ var $row = $('<div/>', {class: 'row'});
+ var columns = this._getColumns();
+ var colClass = 'col-lg-' + (12 / columns);
+ var $container = this._replaceContent($row);
+
+ _.each(imgs, function (img, index) {
+ var $img = $(img);
+ var $col = $('<div/>', {class: colClass});
+ $col.append($img).appendTo($row);
+ if ((index + 1) % columns === 0) {
+ $row = $('<div/>', {class: 'row'});
+ $row.appendTo($container);
+ }
+ });
+ this.$target.css('height', '');
+ },
+ /**
+ * Allows to changes the interval of automatic slideshow (not active in
+ * edit mode).
+ */
+ interval: function (previewMode, widgetValue) {
+ this.$target.find('.carousel:first').attr('data-interval', widgetValue || '0');
+ },
+ /**
+ * Displays the images with the "masonry" layout.
+ */
+ masonry: function () {
+ var self = this;
+ var imgs = this._getImages();
+ var columns = this._getColumns();
+ var colClass = 'col-lg-' + (12 / columns);
+ var cols = [];
+
+ var $row = $('<div/>', {class: 'row'});
+ this._replaceContent($row);
+
+ // Create columns
+ for (var c = 0; c < columns; c++) {
+ var $col = $('<div/>', {class: 'col o_snippet_not_selectable ' + colClass});
+ $row.append($col);
+ cols.push($col[0]);
+ }
+
+ // Dispatch images in columns by always putting the next one in the
+ // smallest-height column
+ while (imgs.length) {
+ var min = Infinity;
+ var $lowest;
+ _.each(cols, function (col) {
+ var $col = $(col);
+ var height = $col.is(':empty') ? 0 : $col.find('img').last().offset().top + $col.find('img').last().height() - self.$target.offset().top;
+ if (height < min) {
+ min = height;
+ $lowest = $col;
+ }
+ });
+ $lowest.append(imgs.pop());
+ }
+ },
+ /**
+ * Allows to change the images layout. @see grid, masonry, nomode, slideshow
+ *
+ * @see this.selectClass for parameters
+ */
+ mode: function (previewMode, widgetValue, params) {
+ widgetValue = widgetValue || 'slideshow'; // FIXME should not be needed
+ this.$target.css('height', '');
+ this[widgetValue]();
+ this.$target
+ .removeClass('o_nomode o_masonry o_grid o_slideshow')
+ .addClass('o_' + widgetValue);
+ this.trigger_up('cover_update');
+ },
+ /**
+ * Displays the images with the standard layout: floating images.
+ */
+ nomode: function () {
+ var $row = $('<div/>', {class: 'row'});
+ var imgs = this._getImages();
+
+ this._replaceContent($row);
+
+ _.each(imgs, function (img) {
+ var wrapClass = 'col-lg-3';
+ if (img.width >= img.height * 2 || img.width > 600) {
+ wrapClass = 'col-lg-6';
+ }
+ var $wrap = $('<div/>', {class: wrapClass}).append(img);
+ $row.append($wrap);
+ });
+ },
+ /**
+ * Allows to remove all images. Restores the snippet to the way it was when
+ * it was added in the page.
+ *
+ * @see this.selectClass for parameters
+ */
+ removeAllImages: function (previewMode) {
+ var $addImg = $('<div>', {
+ class: 'alert alert-info css_editable_mode_display text-center',
+ });
+ var $text = $('<span>', {
+ class: 'o_add_images',
+ style: 'cursor: pointer;',
+ text: _t(" Add Images"),
+ });
+ var $icon = $('<i>', {
+ class: ' fa fa-plus-circle',
+ });
+ this._replaceContent($addImg.append($icon).append($text));
+ },
+ /**
+ * Displays the images with a "slideshow" layout.
+ */
+ slideshow: function () {
+ var imgStyle = this.$el.find('.active[data-styling]').data('styling') || '';
+ var urls = _.map(this._getImages(), function (img) {
+ return $(img).attr('src');
+ });
+ var currentInterval = this.$target.find('.carousel:first').attr('data-interval');
+ var params = {
+ srcs: urls,
+ index: 0,
+ title: "",
+ interval: currentInterval || this.$target.data('interval') || 0,
+ id: 'slideshow_' + new Date().getTime(),
+ userStyle: imgStyle,
+ },
+ $slideshow = $(qweb.render('website.gallery.slideshow', params));
+ this._replaceContent($slideshow);
+ _.each(this.$('img'), function (img, index) {
+ $(img).attr({contenteditable: true, 'data-index': index});
+ });
+ this.$target.css('height', Math.round(window.innerHeight * 0.7));
+
+ // Apply layout animation
+ this.$target.off('slide.bs.carousel').off('slid.bs.carousel');
+ this.$('li.fa').off('click');
+ },
+ /**
+ * Allows to change the style of the individual images.
+ *
+ * @see this.selectClass for parameters
+ */
+ styling: function (previewMode, value) {
+ var classes = _.map(this.$el.find('[data-styling]'), function (el) {
+ return $(el).data('styling');
+ }).join(' ');
+ this.$('img').removeClass(classes).addClass(value);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles image removals and image index updates.
+ *
+ * @override
+ */
+ notify: async function (name, data) {
+ await this._super(...arguments);
+ if (name === 'image_removed') {
+ data.$image.remove(); // Force the removal of the image before reset
+ this.mode('reset', this.getMode());
+ } else if (name === 'image_index_request') {
+ var imgs = this._getImages();
+ var position = _.indexOf(imgs, data.$image[0]);
+ imgs.splice(position, 1);
+ switch (data.position) {
+ case 'first':
+ imgs.unshift(data.$image[0]);
+ break;
+ case 'prev':
+ imgs.splice(position - 1, 0, data.$image[0]);
+ break;
+ case 'next':
+ imgs.splice(position + 1, 0, data.$image[0]);
+ break;
+ case 'last':
+ imgs.push(data.$image[0]);
+ break;
+ }
+ _.each(imgs, function (img, index) {
+ // Note: there might be more efficient ways to do that but it is
+ // more simple this way and allows compatibility with 10.0 where
+ // indexes were not the same as positions.
+ $(img).attr('data-index', index);
+ });
+ this.mode('reset', this.getMode());
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ switch (methodName) {
+ case 'mode': {
+ let activeModeName = 'slideshow';
+ for (const modeName of params.possibleValues) {
+ if (this.$target.hasClass(`o_${modeName}`)) {
+ activeModeName = modeName;
+ break;
+ }
+ }
+ this.activeMode = activeModeName;
+ return activeModeName;
+ }
+ case 'interval': {
+ const carousel = this.$target[0].querySelector('.carousel');
+ return (carousel && carousel.dataset.interval || '0');
+ }
+ case 'columns': {
+ return `${this._getColumns()}`;
+ }
+ case 'styling': {
+ const img = this.$target[0].querySelector('img');
+ if (!img) {
+ return '';
+ }
+ for (const className of params.possibleValues) {
+ if (className && img.classList.contains(className)) {
+ return className;
+ }
+ }
+ return '';
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Returns the images, sorted by index.
+ *
+ * @private
+ * @returns {DOMElement[]}
+ */
+ _getImages: function () {
+ var imgs = this.$('img').get();
+ var self = this;
+ imgs.sort(function (a, b) {
+ return self._getIndex(a) - self._getIndex(b);
+ });
+ return imgs;
+ },
+ /**
+ * Returns the index associated to a given image.
+ *
+ * @private
+ * @param {DOMElement} img
+ * @returns {integer}
+ */
+ _getIndex: function (img) {
+ return img.dataset.index || 0;
+ },
+ /**
+ * Returns the currently selected column option.
+ *
+ * @private
+ * @returns {integer}
+ */
+ _getColumns: function () {
+ return parseInt(this.$target.attr('data-columns')) || 3;
+ },
+ /**
+ * Empties the container, adds the given content and returns the container.
+ *
+ * @private
+ * @param {jQuery} $content
+ * @returns {jQuery} the main container of the snippet
+ */
+ _replaceContent: function ($content) {
+ var $container = this.$('.container:first');
+ $container.empty().append($content);
+ return $container;
+ },
+});
+
+options.registry.gallery_img = options.Class.extend({
+ /**
+ * Rebuilds the whole gallery when one image is removed.
+ *
+ * @override
+ */
+ onRemove: function () {
+ this.trigger_up('option_update', {
+ optionName: 'gallery',
+ name: 'image_removed',
+ data: {
+ $image: this.$target,
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Allows to change the position of an image (its order in the image set).
+ *
+ * @see this.selectClass for parameters
+ */
+ position: function (previewMode, widgetValue, params) {
+ this.trigger_up('deactivate_snippet');
+ this.trigger_up('option_update', {
+ optionName: 'gallery',
+ name: 'image_index_request',
+ data: {
+ $image: this.$target,
+ position: widgetValue,
+ },
+ });
+ },
+});
+});