diff options
Diffstat (limited to 'addons/website')
| -rw-r--r-- | addons/website/__manifest__.py | 1 | ||||
| -rw-r--r-- | addons/website/static/src/js/content/snippets.animation.js | 927 | ||||
| -rw-r--r-- | addons/website/static/src/js/editor/snippets.options.js | 1304 | ||||
| -rw-r--r-- | addons/website/static/src/snippets/s_chart/000.js | 141 | ||||
| -rw-r--r-- | addons/website/static/src/snippets/s_chart/options.js | 476 | ||||
| -rw-r--r-- | addons/website/views/assets.xml | 1 | ||||
| -rw-r--r-- | addons/website/views/snippets/s_chart.xml | 78 | ||||
| -rw-r--r-- | addons/website/views/snippets/snippets.xml | 562 |
8 files changed, 3490 insertions, 0 deletions
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 1ceed0edec6..0e16b446046 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -53,6 +53,7 @@ 'views/snippets/s_faq_collapse.xml', 'views/snippets/s_features_grid.xml', 'views/snippets/s_tabs.xml', + 'views/snippets/s_chart.xml', 'views/website_views.xml', 'views/website_visitor_views.xml', 'views/res_config_settings_views.xml', 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..70581af2540 --- /dev/null +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -0,0 +1,927 @@ +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'); +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; + +// 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"> </div>' + + '<div class="media_iframe_video_size"> </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); + }, +}); + +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..182e6fc8b52 --- /dev/null +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -0,0 +1,1304 @@ +odoo.define('website.editor.snippets.options', function (require) { +'use strict'; + +var core = require('web.core'); +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.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; + } + }, +}); + +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/snippets/s_chart/000.js b/addons/website/static/src/snippets/s_chart/000.js new file mode 100644 index 00000000000..865190fc81b --- /dev/null +++ b/addons/website/static/src/snippets/s_chart/000.js @@ -0,0 +1,141 @@ +odoo.define('website.s_chart', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); + +const ChartWidget = 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; + }, +}); + +publicWidget.registry.chart = ChartWidget; + +return ChartWidget; +}); diff --git a/addons/website/static/src/snippets/s_chart/options.js b/addons/website/static/src/snippets/s_chart/options.js new file mode 100644 index 00000000000..a9429008fee --- /dev/null +++ b/addons/website/static/src/snippets/s_chart/options.js @@ -0,0 +1,476 @@ +odoo.define('website.s_chart_options', function (require) { +'use strict'; + +var core = require('web.core'); +const ColorpickerDialog = require('web.ColorpickerDialog'); +var options = require('web_editor.snippets.options'); + +var _t = core._t; + +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(); + }, +}); +}); diff --git a/addons/website/views/assets.xml b/addons/website/views/assets.xml index 39137714e4c..5b2d8c6c0c6 100644 --- a/addons/website/views/assets.xml +++ b/addons/website/views/assets.xml @@ -111,6 +111,7 @@ <script type="text/javascript" src="/website/static/src/snippets/s_facebook_page/options.js"/> <script type="text/javascript" src="/website/static/src/snippets/s_image_gallery/options.js"/> <script type="text/javascript" src="/website/static/src/snippets/s_countdown/options.js"/> + <script type="text/javascript" src="/website/static/src/snippets/s_chart/options.js"/> <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone.js"/> <script type="text/javascript" src="/website/static/src/js/editor/wysiwyg_multizone_translate.js"/> diff --git a/addons/website/views/snippets/s_chart.xml b/addons/website/views/snippets/s_chart.xml new file mode 100644 index 00000000000..574e78a9c52 --- /dev/null +++ b/addons/website/views/snippets/s_chart.xml @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + +<template id="s_chart" name="Chart"> + <div class="s_chart" data-type="bar" data-legend-position="top" data-tooltip-display="true" data-stacked="false" data-border-width="2" + data-data="{ + "labels":["First","Second","Third","Fourth","Fifth"], + "datasets":[ + { + "label":"One", + "data":["12","24","18","17","10"], + "backgroundColor":"alpha", + "borderColor":"alpha" + } + ] + }"> + <h2>A Chart Title</h2> + <canvas/> + </div> +</template> + +<template id="s_chart_options" inherit_id="website.snippet_options"> + <xpath expr="." position="inside"> + <div data-js="InnerChart" string="Chart" + data-selector=".s_chart"> + <we-colorpicker string="Background Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + <we-select string="Type" data-attribute-name="type" data-attribute-default-value="bar" data-no-preview="true"> + <we-button data-select-data-attribute="bar" data-name="bar_chart_opt">Bar Vertical</we-button> + <we-button data-select-data-attribute="horizontalBar" data-name="horizontal_bar_chart_opt">Bar Horizontal</we-button> + <we-button data-select-data-attribute="line">Line</we-button> + <we-button data-select-data-attribute="pie">Pie</we-button> + <we-button data-select-data-attribute="doughnut">Doughnut</we-button> + <we-button data-select-data-attribute="radar">Radar</we-button> + </we-select> + <we-checkbox string="Stacked" data-name="stacked_chart_opt" data-dependencies="bar_chart_opt, horizontal_bar_chart_opt" + data-select-data-attribute="false|true" data-attribute-name="stacked" data-no-preview="true"/> + <we-select string="Legend" data-attribute-name="legendPosition" data-attribute-default-value="top" data-no-preview="true"> + <we-button data-select-data-attribute="none">None</we-button> + <we-button data-select-data-attribute="top">Top</we-button> + <we-button data-select-data-attribute="left">Left</we-button> + <we-button data-select-data-attribute="bottom">Bottom</we-button> + <we-button data-select-data-attribute="right">Right</we-button> + </we-select> + <we-checkbox string="Tooltip" data-select-data-attribute="false|true" data-attribute-name="tooltipDisplay" data-no-preview="true"/> + <we-matrix> + <table> + <tr> + <th/> + <th><we-button class="fa fa-fw fa-plus add_column"/></th> + </tr> + <tr> + <th><we-button class="fa fa-fw fa-plus add_row"/></th> + </tr> + </table> + </we-matrix> + <we-colorpicker string="Background" data-name="chart_bg_color_opt" + data-color-change="" + data-attribute-name="backgroundColor" + data-no-preview="true"/> + <we-colorpicker string="Border" data-name="chart_border_color_opt" + data-color-change="" + data-attribute-name="borderColor" + data-no-preview="true"/> + <we-input class="o_we_inline" string="Border Width" data-select-data-attribute="2px" data-attribute-name="borderWidth" data-unit="px"/> + </div> + </xpath> +</template> + +<template id="assets_snippet_s_chart_js_000" inherit_id="website.assets_frontend"> + <xpath expr="//script[last()]" position="after"> + <script type="text/javascript" src="/website/static/src/snippets/s_chart/000.js"/> + </xpath> +</template> + +</odoo> diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml new file mode 100644 index 00000000000..e7abb71f560 --- /dev/null +++ b/addons/website/views/snippets/snippets.xml @@ -0,0 +1,562 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + +<template id="s_parallax" name="Parallax"> + <section class="s_parallax parallax s_parallax_is_fixed bg-black-50 pt160 pb160" data-scroll-background-ratio="1"> + <span class="s_parallax_bg oe_img_bg oe_custom_bg" style="background-image: url('/web/image/website.s_parallax_default_image'); background-position: 50% 75%;"/> + <div class="oe_structure"> + <section> + <div class="container"> + <div class="row s_nb_column_fixed"> + <div class="col-lg-12 s_title pt16 pb16" style="text-align: center;"> + <h1 class="s_title_boxed"><font style="font-size: 62px;">Your Site Title</font></h1> + </div> + </div> + </div> + </section> + </div> + </section> +</template> + +<template id="s_quotes_carousel" name="Quotes"> + <section class="s_quotes_carousel_wrapper"> + <div id="myQuoteCarousel" class="s_quotes_carousel s_carousel_default carousel slide" data-interval="10000"> + <!-- Indicators --> + <ol class="carousel-indicators"> + <li data-target="#myQuoteCarousel" data-slide-to="0" class="active"></li> + <li data-target="#myQuoteCarousel" data-slide-to="1"></li> + <li data-target="#myQuoteCarousel" data-slide-to="2"></li> + </ol> + <!-- Content --> + <div class="carousel-inner"> + <!-- #01 --> + <div class="carousel-item active bg-200 pt80 pb80" data-name="Slide"> + <div class="container"> + <div class="row content"> + <blockquote class="carousel-content col-lg-6 bg-white offset-lg-3"> + <i class="fa fa-1x fa-quote-left rounded-0 bg-secondary s_quotes_carousel_icon"/> + <p>Write a quote here from one of your customers. Quotes are a great way to build confidence in your products or services.</p> + <footer> + <img class="img" src="/web/image/website.s_quotes_carousel_demo_image_3" alt=""/> + <span>— Jane DOE, CEO of <b>MyCompany</b></span> + </footer> + </blockquote> + </div> + </div> + </div> + <!-- #02 --> + <div class="carousel-item oe_img_bg pt80 pb80" style="background-image: url('/web/image/website.s_quotes_carousel_demo_image_1'); background-position: 50% 50%;" data-name="Slide"> + <div class="container"> + <div class="row content"> + <blockquote class="col-lg-6 bg-white"> + <p>Odoo provides essential platform for our project management. Things are better organized and more visible with it.</p> + <footer> + <img class="img" src="/web/image/website.s_quotes_carousel_demo_image_4" alt=""/> + <span>— John DOE, CEO of <b>MyCompany</b></span> + </footer> + </blockquote> + </div> + </div> + </div> + <!-- #03 --> + <div class="carousel-item oe_img_bg pt80 pb80" style="background-image: url('/web/image/website.s_quotes_carousel_demo_image_2'); background-position: 50% 50%;" data-name="Slide"> + <div class="container"> + <div class="row content"> + <blockquote class="col-lg-6 offset-lg-6 bg-white"> + <p>Odoo provides essential platform for our project management. Things are better organized and more visible with it.</p> + <footer class="blockquote-footer"> + <img class="img" src="/web/image/website.s_quotes_carousel_demo_image_5" alt=""/> + <span>— Jane DOE, CEO of <b>MyCompany</b></span> + </footer> + </blockquote> + </div> + </div> + </div> + </div> + <!-- Controls --> + <div class="carousel-control-prev" data-target="#myQuoteCarousel" data-slide="prev" role="img" aria-label="Previous" title="Previous"> + <span class="carousel-control-prev-icon"/> + <span class="sr-only">Previous</span> + </div> + <div class="carousel-control-next" data-target="#myQuoteCarousel" data-slide="next" role="img" aria-label="Next" title="Next"> + <span class="carousel-control-next-icon"/> + <span class="sr-only">Next</span> + </div> + </div> + </section> +</template> + +<!-- Mega menu snippets --> + +<template id="s_mega_menu_multi_menus" name="Multi-Menus"> + <section class="s_mega_menu_multi_menus py-4"> + <div class="container"> + <div class="row"> + <t t-set="menu1_title">First Menu</t> + <t t-set="menu2_title">Second Menu</t> + <t t-set="menu3_title">Third Menu</t> + <t t-set="menu4_title">Last Menu</t> + <t t-foreach="[menu1_title, menu2_title, menu3_title, menu4_title]" t-as="menu_title"> + <div class="col-lg-3 py-2 text-center"> + <h4 t-esc="menu_title"/> + <nav class="nav flex-column"> + <t t-foreach="3" t-as="i"> + <t t-set="text">Menu Item %s</t> + <t t-set="text" t-value="text % (i + 1)"/> + <a href="#" class="nav-link" data-name="Menu Item" + t-esc="text"/> + </t> + </nav> + </div> + </t> + </div> + </div> + </section> +</template> + +<template id="s_mega_menu_menu_image_menu" name="Menu - Image - Menu"> + <section class="s_mega_menu_menu_image_menu py-4"> + <div class="container"> + <div class="row align-items-center"> + <div class="col-lg-4 py-2 text-center"> + <h4>Left Menu</h4> + <nav class="nav flex-column"> + <t t-foreach="3" t-as="i"> + <t t-set="text">Menu Item %s</t> + <t t-set="text" t-value="text % (i + 1)"/> + <a href="#" class="nav-link" data-name="Menu Item" + t-esc="text"/> + </t> + </nav> + </div> + <div class="col-lg-4 py-2 text-center"> + <img class="img-fluid" src="/web/image/website.s_mega_menu_menu_image_menu_default_image"/> + </div> + <div class="col-lg-4 py-2 text-center"> + <h4>Right Menu</h4> + <nav class="nav flex-column"> + <t t-foreach="3" t-as="i"> + <t t-set="text">Menu Item %s</t> + <t t-set="text" t-value="text % (i + 1)"/> + <a href="#" class="nav-link" data-name="Menu Item" + t-esc="text"/> + </t> + </nav> + </div> + </div> + </div> + </section> +</template> + +<!-- Snippets menu --> +<template id="snippets" inherit_id="web_editor.snippets" primary="True"> + <xpath expr="//div[@id='o_scroll']" position="replace"> + <div id="o_scroll"> + <div id="snippet_mega_menu" class="o_panel d-none"> + <div class="o_panel_header"> + <i class="fa fa-puzzle-piece"/> Mega Menu + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_mega_menu_multi_menus" t-thumbnail="/website/static/src/img/snippets_thumbs/s_mega_menu_multi_menus.png"/> + <t t-snippet="website.s_mega_menu_menu_image_menu" t-thumbnail="/website/static/src/img/snippets_thumbs/s_mega_menu_menu_image_menu.png"/> + </div> + </div> + + <div id="snippet_structure" class="o_panel"> + <div class="o_panel_header"> + <i class="fa fa-th-large"/> Structure + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_banner" t-thumbnail="/website/static/src/img/snippets_thumbs/s_banner.png"/> + <t t-snippet="website.s_cover" t-thumbnail="/website/static/src/img/snippets_thumbs/s_cover.png"/> + <t t-snippet="website.s_text_image" t-thumbnail="/website/static/src/img/snippets_thumbs/s_text_image.png"/> + <t t-snippet="website.s_image_text" t-thumbnail="/website/static/src/img/snippets_thumbs/s_image_text.png"/> + <t t-snippet="website.s_title" t-thumbnail="/website/static/src/img/snippets_thumbs/s_title.png"/> + <t t-snippet="website.s_text_block" t-thumbnail="/website/static/src/img/snippets_thumbs/s_text_block.png"/> + <t t-snippet="website.s_picture" t-thumbnail="/website/static/src/img/snippets_thumbs/s_picture.png"/> + <t t-snippet="website.s_carousel" t-thumbnail="/website/static/src/img/snippets_thumbs/s_carousel.png"/> + <t t-snippet="website.s_features" t-thumbnail="/website/static/src/img/snippets_thumbs/s_features.png"/> + <t t-snippet="website.s_three_columns" t-thumbnail="/website/static/src/img/snippets_thumbs/s_three_columns.png"/> + </div> + </div> + + <div id="snippet_feature" class="o_panel"> + <div class="o_panel_header"> + <i class="fa fa-diamond"/> Features + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_image_gallery" t-thumbnail="/website/static/src/img/snippets_thumbs/s_image_gallery.png"/> + <t t-snippet="website.s_comparisons" t-thumbnail="/website/static/src/img/snippets_thumbs/s_comparison.png"/> + <t t-snippet="website.s_company_team" t-thumbnail="/website/static/src/img/snippets_thumbs/s_company_team.png"/> + <t t-snippet="website.s_call_to_action" t-thumbnail="/website/static/src/img/snippets_thumbs/s_call_to_action.png"/> + <t t-snippet="website.s_references" t-thumbnail="/website/static/src/img/snippets_thumbs/s_references.png"/> + <t t-snippet="website.s_faq_collapse" t-thumbnail="/website/static/src/img/snippets_thumbs/s_faq.png"/> + <t t-snippet="website.s_features_grid" t-thumbnail="/website/static/src/img/snippets_thumbs/s_features_grid.png"/> + <t t-snippet="website.s_tabs" t-thumbnail="/website/static/src/img/snippets_thumbs/s_tabs.png"/> + <t t-snippet="website.s_countdown" t-thumbnail="/website/static/src/img/snippets_thumbs/s_countdown.png"/> + </div> + </div> + + <div id="snippet_effect" class="o_panel"> + <div class="o_panel_header"> + <i class="fa fa-magic icon-fix"/> Effects + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_parallax" t-thumbnail="/website/static/src/img/snippets_thumbs/s_parallax.png"/> + <t t-snippet="website.s_quotes_carousel" t-thumbnail="/website/static/src/img/snippets_thumbs/s_quotes_carousel.png"/> + </div> + </div> + + <div id="snippet_content" class="o_panel"> + <div class="o_panel_header"> + <i class="fa fa-indent"/> Inner content + </div> + <div class="o_panel_body"> + <t t-snippet="website.s_btn" t-thumbnail="/website/static/src/img/snippets_thumbs/s_button.png"/> + <t t-snippet="website.s_hr" t-thumbnail="/website/static/src/img/snippets_thumbs/s_separator.png"/> + <t t-snippet="website.s_alert" t-thumbnail="/website/static/src/img/snippets_thumbs/s_alert.png"/> + <t t-snippet="website.s_card" t-thumbnail="/website/static/src/img/snippets_thumbs/s_card.png"/> + <t t-snippet="website.s_share" t-thumbnail="/website/static/src/img/snippets_thumbs/s_share.png"/> + <t t-snippet="website.s_facebook_page" t-thumbnail="/website/static/src/img/snippets_thumbs/s_facebook_page.png"/> + <t t-snippet="website.s_rating" t-thumbnail="/website/static/src/img/snippets_thumbs/s_rating.png"/> + <t t-snippet="website.s_chart" t-thumbnail="/website/static/src/img/snippets_thumbs/s_chart.png"/> + </div> + </div> + </div> + </xpath> + + <xpath expr="//div[@id='snippet_options']/t" position="attributes"> + <attribute name="t-call">website.snippet_options</attribute> + </xpath> +</template> + +<template id="external_snippets" inherit_id="website.snippets" priority="8"> + <xpath expr="//div[@id='snippet_feature']//t[@t-snippet][last()]" position="after"> + <t id="newsletter_popup_snippet" t-install="mass_mailing" string="Newsletter Popup" t-thumbnail="/website/static/src/img/snippets_thumbs/newsletter_subscribe_popup.png"/> + <t id="newsletter_block_snippet" t-install="mass_mailing" string="Newsletter Block" t-thumbnail="/website/static/src/img/snippets_thumbs/s_newsletter_block.png"/> + <t t-install="website_twitter" string="Twitter Scroller" t-thumbnail="/website/static/src/img/snippets_thumbs/s_twitter_scroll.png"/> + <t t-install="website_form" string="Form Builder" t-thumbnail="/website/static/src/img/s_website_form.png"/> + <t id="s_products_searchbar" t-install="website_sale" string="Products Search" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/> + <t id="s_products_recently_viewed" t-install="website_sale" string="Products Recently Viewed" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_recently_viewed.png"/> + </xpath> + <xpath expr="//div[@id='snippet_content']//t[@t-snippet][last()]" position="after"> + <t t-install="website_event" string="Local Events" t-thumbnail="/website/static/src/img/snippets_thumbs/s_local_events.png"/> + <t t-install="website_mail_channel" string="Discussion Group" t-thumbnail="/website/static/src/img/snippets_thumbs/s_button_channel_subscribe.png"/> + <t id="newsletter_snippet" t-install="mass_mailing" string="Newsletter" t-thumbnail="/website/static/src/img/snippets_thumbs/s_newsletter_subscribe_form.png"/> + <t id="s_products_searchbar_input" t-install="website_sale" string="Products Search Input" t-thumbnail="/website/static/src/img/snippets_thumbs/s_products_searchbar.png"/> + </xpath> +</template> + +<template id="snippet_options"> + <t t-call="web_editor.snippet_options"/> + + <!-- COLOR | .s_three_columns | .s_comparisons --> + <div data-selector=".s_three_columns .row > div, .s_comparisons .row > div, .s_tabs .row > div" + data-target=".card"> + <we-colorpicker string="Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + </div> + + <!-- COLOR | .s_cards --> + <div data-selector=".s_card, .accordion .card"> + <we-colorpicker string="Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + </div> + + <!-- H-ALIGN | .s_btn --> + <div data-selector=".s_btn, .s_share"> + <we-select string="Alignment"> + <we-button data-select-class="text-left">Left</we-button> + <we-button data-select-class="text-center">Center</we-button> + <we-button data-select-class="text-right">Right</we-button> + </we-select> + </div> + + <div data-selector=".s_alert, .s_card"> + <we-select string="Width"> + <we-button data-select-class="w-25">25%</we-button> + <we-button data-select-class="w-50">50%</we-button> + <we-button data-select-class="w-75">75%</we-button> + <we-button data-select-class="w-100">100%</we-button> + </we-select> + </div> + + <!-- Carousel | .s_carousel | .s_quotes_carousel --> + <div data-js="Carousel" + data-selector="section" + data-target="> .carousel"> + <we-select string="Transition"> + <we-button data-select-class="slide">Slide</we-button> + <we-button data-select-class="carousel-fade slide">Fade</we-button> + <we-divider/> + <we-button data-select-class="">None</we-button> + </we-select> + <we-select string="Style"> + <we-button data-select-class="s_carousel_bordered">Bordered</we-button> + <we-button data-select-class="s_carousel_boxed">Boxed</we-button> + <we-button data-select-class="s_carousel_rounded">Rounded</we-button> + <we-divider/> + <we-button data-select-class="s_carousel_default">Default</we-button> + </we-select> + <we-input string="Speed" class="o_we_inline" + data-select-data-attribute="0s" data-attribute-name="interval" + data-unit="s" data-save-unit="ms" data-step="0.1"/> + </div> + + <div data-js="CarouselItem" + data-selector=".carousel-item"> + <we-button data-slide="left" data-no-preview="true" class="fa fa-fw fa-angle-left"/> + <we-button data-slide="right" data-no-preview="true" class="fa fa-fw fa-angle-right"/> + <we-button data-add-slide="true" data-no-preview="true" class="fa fa-fw fa-plus"/> + <we-button data-remove-slide="true" data-no-preview="true" class="fa fa-fw fa-minus"/> + </div> + + <div data-js="navTabs" data-selector="section .row > div" data-target=".nav-tabs"> + <we-button data-add-tab="1" data-no-preview="true">Add Tab</we-button> + <we-button data-remove-tab="1" data-name="remove_tab_opt" data-no-preview="true">Remove Tab</we-button> + </div> + + <!-- Accordion --> + <div data-js="collapse" + data-selector='.accordion > .card' + data-drop-in='.accordion:has(> .card)'/> + + <div data-js="layout_column" + data-selector="section" + data-target="> * > .row:not(.s_nb_column_fixed)"> + <we-select string="Number of columns" data-no-preview="true"> + <we-button data-select-count="1">1</we-button> + <we-button data-select-count="2">2</we-button> + <we-button data-select-count="3">3</we-button> + <we-button data-select-count="4">4</we-button> + <we-button data-select-count="5">5</we-button> + <we-button data-select-count="6">6</we-button> + </we-select> + </div> + + <!-- Move snippets around --> + <div data-js="SnippetMove" + data-selector="section"> + <we-button class="fa fa-fw fa-angle-up" data-move-snippet="prev" data-no-preview="true"/> + <we-button class="fa fa-fw fa-angle-down" data-move-snippet="next" data-no-preview="true"/> + </div> + <div data-js="SnippetMove" + data-selector=".row:not(.s_col_no_resize) > div"> + <we-button class="fa fa-fw fa-angle-left" data-move-snippet="prev" data-no-preview="true"/> + <we-button class="fa fa-fw fa-angle-right" data-move-snippet="next" data-no-preview="true"/> + </div> + + <!-- V-ALIGN --> + <div id="row_valign_snippet_option" data-selector=".s_text_image, .s_image_text, .s_three_columns" data-target=".row"> + <we-select string="Alignment"> + <we-button data-select-class="align-items-start">Top</we-button> + <we-button data-select-class="align-items-center">Middle</we-button> + <we-button data-select-class="align-items-end">Bottom</we-button> + <we-divider/> + <we-button data-select-class="align-items-stretch">Equal height</we-button> + </we-select> + </div> + + <!-- Background Image --> + <div data-js="background" string="Background" + data-selector="section, .parallax, .carousel-item" + data-exclude=".s_hr, .s_image_gallery, .o_gallery .carousel-item"/> + + <!-- Background Image Size/Position --> + <div data-js="BackgroundPosition" + data-selector="section, .parallax, .carousel-item" + data-exclude=".o_gallery .carousel-item"> + <we-row> + <we-select data-no-preview="true"> + <we-button data-background-type="cover">Cover</we-button> + <we-button data-background-type="repeat-pattern" data-name="background_repeat_opt">Repeat pattern</we-button> + </we-select> + <we-button class="fa fa-fw fa-arrows" title="Background Position" + data-background-position-overlay="true" data-no-preview="true"/> + </we-row> + <we-multi data-css-property="background-size" data-dependencies="background_repeat_opt"> + <we-input string="Width" data-select-style="auto" data-unit="px"/> + <we-input string="Height" data-select-style="auto" data-unit="px"/> + </we-multi> + </div> + + <!-- Parallax --> + <div data-js="parallax" data-selector=".parallax"> + <we-select string="Scroll Speed" data-attribute-name="scrollBackgroundRatio" data-attribute-default-value="0"> + <we-button data-select-data-attribute="0">No-scroll</we-button> + <we-divider/> + <we-button data-select-data-attribute="1">Fixed</we-button> + <we-divider/> + <we-button data-select-data-attribute="0.6">Very Slow</we-button> + <we-button data-select-data-attribute="1.2">Slow</we-button> + <we-button data-select-data-attribute="1.6">Fast</we-button> + <we-button data-select-data-attribute="2">Very Fast</we-button> + </we-select> + </div> + + <!-- Color | Section --> + <div id="so_main_colorpicker" + data-selector="section, .carousel-item" + data-exclude=".parallax, .o_gallery .carousel-item"> + <we-colorpicker string="Background Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + </div> + + <!-- FILTER | .s_parallax --> + <div data-selector=".parallax"> + <we-colorpicker string="Filter" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-" + data-excluded="common, theme, custom"/> + </div> + + <!-- Color | Columns --> + <div data-selector="section .row > div" + data-exclude=".s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .o_gallery .row > div"> + <we-colorpicker string="Background Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-"/> + </div> + + <div data-selector="section .row > div" + data-exclude=".s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .o_gallery .row > div"> + <we-row string="Border" class="o_we_inline"> + <we-colorpicker data-dependencies="border_width_opt" + data-select-style="true" data-css-property="border-color" data-color-prefix="border-"/> + <we-input class="o_we_inline" data-name="border_width_opt" + data-select-style="0" data-css-property="border-width" + data-unit="px" data-extra-class="border"/> + </we-row> + <we-input string="Rounded" class="o_we_inline" + data-dependencies="border_width_opt" + data-select-style="0" data-css-property="border-radius" + data-unit="px" data-extra-class="rounded"/> + <we-checkbox string="Shadow" data-select-class="shadow"/> + </div> + + <div data-js="sizing_y" + data-selector="section, .row > div, .parallax, .s_hr, .s_btn, .carousel-item" + data-exclude="section:has(> .carousel), .o_gallery .carousel-item"/> + + <div data-js="sizing_x" + data-selector=".row > div" + data-drop-near=".row > div" + data-exclude=".s_col_no_resize.row > div"/> + + <div id="so_snippet_addition" + data-selector="section, .parallax" + data-drop-in=":not(p).oe_structure:not(.oe_structure_solo), :not(p)[data-oe-type=html], :not(p).oe_structure.oe_structure_solo:not(:has(> section, > div))"/> + + <!-- Main content drop --> + <div id="so_content_addition" + data-selector="blockquote, .s_btn, .s_card, .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_countdown, .s_chart" + data-drop-near="p, h1, h2, h3, blockquote, .s_btn, .s_card, .s_alert, .o_facebook_page, .s_share, .s_rating, .s_hr, .s_chart" + data-drop-in=".content, nav"/> + + <div data-js="ul" + data-selector=":not(li) > ul:has(ul,ol), :not(li) > ol:has(ul,ol)"> + <we-checkbox string="Folded list" data-select-class="o_ul_folded"/> + </div> + + <div data-js="menu_data" + data-selector="#top_menu li > a" + data-exclude=".dropdown-toggle" + data-no-check="true"/> + + <div data-js="company_data" + data-selector="[data-oe-expression='res_company.partner_id']" + data-no-check="true"/> + + <div data-js="topMenuTransparency" + data-selector="[data-main-object^='website.page('] #wrapwrap > header" + data-no-check="true"> + <we-checkbox string="Transparent" data-transparent="true" data-no-preview="true"/> + </div> + + <div data-js="topMenuColor" + data-selector="[data-main-object^='website.page('] #wrapwrap > header" + data-no-check="true"> + <we-colorpicker string="Background Color" + data-select-style="true" + data-css-property="background-color" + data-color-prefix="bg-" + data-excluded="theme, common, custom"/> + </div> + + <!-- Anchor Name --> + <div data-js="anchor" + data-selector=":not(p).oe_structure > *, :not(p)[data-oe-type=html] > *" + data-exclude=".modal *"> + <we-button class="fa fa-fw fa-link" + title="Create a link to target this section" + data-no-preview="true"/> + </div> + + <!-- Mega Menu settings --> + <div data-selector=".o_mega_menu"> + <we-select string="Size"> + <we-button data-select-class="">Full-Width</we-button> + <we-button data-select-class="o_mega_menu_container_size">Narrow</we-button> + </we-select> + </div> + + <div data-selector=".o_mega_menu .nav-link" + data-drop-in=".o_mega_menu nav" + data-drop-near=".o_mega_menu .nav-link"/> + + <div data-js="CoverProperties" data-selector=".o_record_cover_container" data-no-check="true"> + <we-button data-change="true" data-no-preview="true">Change Cover</we-button> + <we-button data-clear="true" data-no-preview="true" data-name="remove_cover_opt">Remove Cover</we-button> + <we-select string="Size" data-cover-opt-name="size"> + <we-button data-select-class="o_record_has_cover cover_full">Full Screen</we-button> + <we-button class="o_record_cover_opt_size_default" data-select-class="o_record_has_cover cover_mid">Half Screen</we-button> + <we-button data-select-class="o_record_has_cover cover_auto">Fit text</we-button> + </we-select> + <we-select string="Filter Intensity" data-cover-opt-name="filters"> + <we-button data-filter-value="0.0">None</we-button> + <we-button data-filter-value="0.2">Low</we-button> + <we-button data-filter-value="0.4">Medium</we-button> + <we-button data-filter-value="0.6">High</we-button> + </we-select> + <we-select string="Filter Color" data-cover-opt-name="filters"> + <we-button data-filter-color="oe_black">Black</we-button> + <we-button data-filter-color="oe_none">White</we-button> + <we-button data-filter-color="bg-primary">Primary</we-button> + <we-button data-filter-color="bg-secondary">Secondary</we-button> + <we-button data-filter-color="oe_blue">Blue</we-button> + <we-button data-filter-color="oe_yellow">Yellow</we-button> + <we-button data-filter-color="oe_red">Red</we-button> + <we-button data-filter-color="oe_purple">Purple</we-button> + <we-button data-filter-color="oe_green">Green</we-button> + </we-select> + <we-select string="Text Size" data-cover-opt-name="text_size"> + <we-button data-select-class="o_record_cover_font_hero">Hero</we-button> + <we-button data-select-class="o_record_cover_font_huge">Huge</we-button> + <we-button data-select-class="o_record_cover_font_big">Big</we-button> + <we-button data-select-class="">Regular</we-button> + <we-button data-select-class="o_record_cover_font_small">Small</we-button> + <we-button data-select-class="o_record_cover_font_tiny">Tiny</we-button> + </we-select> + <we-select string="Text Alignment" data-cover-opt-name="text_align"> + <we-button data-select-class="">Left</we-button> + <we-button data-select-class="text-center">Centered</we-button> + <we-button data-select-class="text-right">Right</we-button> + </we-select> + </div> + + <!-- Stretch section --> + <div data-js="SectionStretch" + data-selector="section" + data-target="> .container, > .container-fluid"> + <we-checkbox string="Stretch" data-select-class="container|container-fluid" data-no-preview="true"/> + </div> +</template> +</odoo> |
