diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index b70e143a8cc..de1e12781c9 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -54,6 +54,7 @@ 'views/snippets/s_faq_collapse.xml', 'views/snippets/s_features_grid.xml', 'views/snippets/s_tabs.xml', + 'views/snippets/s_table_of_content.xml', 'views/snippets/s_chart.xml', 'views/snippets/s_parallax.xml', 'views/snippets/s_quotes_carousel.xml', diff --git a/addons/website/static/src/img/snippets_thumbs/s_table_of_content.png b/addons/website/static/src/img/snippets_thumbs/s_table_of_content.png new file mode 100644 index 00000000000..fd1cf6ae2e5 Binary files /dev/null and b/addons/website/static/src/img/snippets_thumbs/s_table_of_content.png differ diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index 0f70408fc47..611ed40b554 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -142,6 +142,7 @@ publicWidget.registry.autohideMenu = publicWidget.Widget.extend({ dom.initAutoMoreMenu(self.$el, {unfoldable: '.divider, .divider ~ li'}); } self.$el.removeClass('o_menu_loading'); + self.$el.trigger('menu_loaded'); }); }, /** diff --git a/addons/website/static/src/snippets/s_table_of_content/000.js b/addons/website/static/src/snippets/s_table_of_content/000.js new file mode 100644 index 00000000000..ca6460df641 --- /dev/null +++ b/addons/website/static/src/snippets/s_table_of_content/000.js @@ -0,0 +1,79 @@ +odoo.define('website.s_table_of_content', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); + +const TableOfContent = publicWidget.Widget.extend({ + selector: 'section .s_table_of_content_navbar_sticky', + disabledInEditableMode: false, + + /** + * @override + */ + start: function () { + const $menuLoading = $('.o_menu_loading'); + if (!$menuLoading.length) { + this._initializeNavbarTopPosition(); + } else { + $(document.body).one('menu_loaded', () => this._initializeNavbarTopPosition()); + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this.$target.find('.s_table_of_content_navbar').css('top', ''); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Initialize the top position of the snippet navbar according to the height + * of the headers of the page. + * + * @private + */ + _initializeNavbarTopPosition: function () { + let headerHeight = 0; + const $fixedElements = $('.o_top_fixed_element'); + const isHorizontalNavbar = this.$target.hasClass('s_table_of_content_horizontal_navbar'); + _.each($fixedElements, el => headerHeight += $(el).outerHeight()); + this.$target.css('top', isHorizontalNavbar ? headerHeight : ''); + this.$target.find('.s_table_of_content_navbar').css('top', !isHorizontalNavbar ? headerHeight : ''); + $('body').scrollspy({target: '.s_table_of_content_navbar', offset: headerHeight + 100}); + }, +}); + +publicWidget.registry.anchorSlide.include({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Overridden to add the height of the horizontal sticky navbar at the scroll value + * when the link is from the table of content navbar + * + * @override + * @private + */ + _computeHeaderHeight: function () { + let headerHeight = this._super(...arguments); + if (this.$el.hasClass('table_of_content_link')) { + const tableOfContentNavbarEl = this.$el.closest('.s_table_of_content_navbar_sticky.s_table_of_content_horizontal_navbar'); + if (tableOfContentNavbarEl.length > 0) { + headerHeight += $(tableOfContentNavbarEl).outerHeight(); + } + } + return headerHeight; + }, +}); + +publicWidget.registry.snippetTableOfContent = TableOfContent; + +return TableOfContent; +}); diff --git a/addons/website/static/src/snippets/s_table_of_content/000.scss b/addons/website/static/src/snippets/s_table_of_content/000.scss new file mode 100644 index 00000000000..a145dad6e4f --- /dev/null +++ b/addons/website/static/src/snippets/s_table_of_content/000.scss @@ -0,0 +1,64 @@ +.s_table_of_content:not([data-vcss]) { + .s_table_of_content_navbar_wrap { + &.s_table_of_content_navbar_sticky { + &.s_table_of_content_horizontal_navbar, &.s_table_of_content_vertical_navbar .s_table_of_content_navbar { + @include o-position-sticky($top: 0px); + } + } + &:not(.s_table_of_content_navbar_sticky) { + &, .s_table_of_content_navbar { + top: 0px !important; + } + } + &.s_table_of_content_vertical_navbar .s_table_of_content_navbar { + > a.list-group-item-action { + background: none; + color: rgba($body-color, 0.7); + font-weight: $font-weight-normal + 100; + padding-left: 3px; + transition: padding 0.1s; + + &:before { + @include o-position-absolute(10px, auto, 10px, 0); + width: 2px; + content: ""; + } + &:hover { + color: rgba($body-color, 0.9); + } + &:focus { + background: none; + } + &.active { + color: rgba($body-color, 0.9); + background: none; + padding-left: 8px; + + &:before { + background-color: theme-color('primary'); + } + } + } + } + &.s_table_of_content_horizontal_navbar { + z-index: 1; + padding-top: $navbar-padding-y; + padding-bottom: $navbar-padding-y; + margin-bottom: $spacer * 2; + + .s_table_of_content_navbar { + display: inline; + + > a { + &.list-group-item-action { + width: auto; + } + &.list-group-item { + display: inline-block; + margin-bottom: 2px; + } + } + } + } + } +} diff --git a/addons/website/static/src/snippets/s_table_of_content/options.js b/addons/website/static/src/snippets/s_table_of_content/options.js new file mode 100644 index 00000000000..f33dcbafd47 --- /dev/null +++ b/addons/website/static/src/snippets/s_table_of_content/options.js @@ -0,0 +1,133 @@ +odoo.define('website.s_table_of_content_options', function (require) { +'use strict'; + +const options = require('web_editor.snippets.options'); + +options.registry.TableOfContent = options.Class.extend({ + /** + * @override + */ + start: function () { + this.targetedElements = 'h1, h2'; + const $headings = this.$target.find(this.targetedElements); + if ($headings.length > 0) { + this.isAnimateScrolling = this.$target.find(this.targetedElements)[0].dataset.anchor === 'true' ? true : false; + this._generateNav(); + } + // Generate the navbar if the content changes + const targetNode = this.$target.find('.s_table_of_content_main')[0]; + const config = {attributes: false, childList: true, subtree: true, characterData: true}; + this.observer = new MutationObserver(() => this._generateNav()); + this.observer.observe(targetNode, config); + return this._super(...arguments); + }, + /** + * @override + */ + onClone: function () { + this._generateNav(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Animate (or not) scrolling. + * + * @see this.selectClass for parameters + */ + animateScrolling: function (previewMode, widgetValue, params) { + const $headings = this.$target.find(this.targetedElements); + const anchorValue = widgetValue ? 'true' : '0'; + _.each($headings, el => el.dataset.anchor = anchorValue); + this.isAnimateScrolling = !!widgetValue; + }, + /** + * Change the navbar position. + * + * @see this.selectClass for parameters + */ + navbarPosition: function (previewMode, widgetValue, params) { + const $navbar = this.$target.find('.s_table_of_content_navbar_wrap'); + const $mainContent = this.$target.find('.s_table_of_content_main'); + if (widgetValue === 'top' || widgetValue === 'left') { + $navbar.prev().before($navbar); + } + if (widgetValue === 'left' || widgetValue === 'right') { + $navbar.removeClass('s_table_of_content_horizontal_navbar col-lg-12').addClass('s_table_of_content_vertical_navbar col-lg-3'); + $mainContent.removeClass('col-lg-12').addClass('col-lg-9'); + $navbar.find('.s_table_of_content_navbar').removeClass('list-group-horizontal-md'); + } + if (widgetValue === 'right') { + $navbar.next().after($navbar); + } + if (widgetValue === 'top') { + $navbar.removeClass('s_table_of_content_vertical_navbar col-lg-3').addClass('s_table_of_content_horizontal_navbar col-lg-12'); + $navbar.find('.s_table_of_content_navbar').addClass('list-group-horizontal-md'); + $mainContent.removeClass('col-lg-9').addClass('col-lg-12'); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _generateNav: function (ev) { + const $nav = this.$target.find('.s_table_of_content_navbar'); + const $headings = this.$target.find(this.targetedElements); + $nav.empty(); + _.each($headings, el => { + const $el = $(el); + const id = 'table_of_content_heading_' + _.now() + '_' + _.uniqueId(); + $('').attr('href', "#" + id) + .addClass('table_of_content_link list-group-item list-group-item-action py-2 border-0 rounded-0') + .text($el.text()) + .appendTo($nav); + $el.attr('id', id); + $el[0].dataset.anchor = this.isAnimateScrolling === true ? 'true' : '0'; + }); + $nav.find('a:first').addClass('active'); + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'animateScrolling': { + const $headings = this.$target.find(this.targetedElements); + if ($headings.length > 0) { + return $headings[0].dataset.anchor === 'true' ? 'true' : '0'; + } else { + return 'true'; + } + } + case 'navbarPosition': { + const $navbar = this.$target.find('.s_table_of_content_navbar_wrap'); + if ($navbar.hasClass('s_table_of_content_horizontal_navbar')) { + return 'top'; + } else { + const $mainContent = this.$target.find('.s_table_of_content_main'); + return $navbar.prev().is($mainContent) === true ? 'right' : 'left'; + } + } + } + return this._super(...arguments); + }, +}); + +options.registry.TableOfContentMainColumns = options.Class.extend({ + /** + * @override + */ + start: function () { + const leftPanelEl = this.$overlay.data('$optionsSection')[0]; + leftPanelEl.querySelector('.oe_snippet_remove').classList.add('d-none'); // TODO improve the way to do that + leftPanelEl.querySelector('.oe_snippet_clone').classList.add('d-none'); // TODO improve the way to do that + return this._super.apply(this, arguments); + }, +}); +}); diff --git a/addons/website/views/assets.xml b/addons/website/views/assets.xml index 3eaf292837f..56500ba06c5 100644 --- a/addons/website/views/assets.xml +++ b/addons/website/views/assets.xml @@ -118,6 +118,7 @@