diff --git a/addons/account/static/src/js/account_portal_sidebar.js b/addons/account/static/src/js/account_portal_sidebar.js index 0b48c0a9647..58114e00a56 100644 --- a/addons/account/static/src/js/account_portal_sidebar.js +++ b/addons/account/static/src/js/account_portal_sidebar.js @@ -1,6 +1,7 @@ odoo.define('account.AccountPortalSidebar', function (require) { 'use strict'; +const dom = require('web.dom'); var publicWidget = require('web.public.widget'); var PortalSidebar = require('portal.PortalSidebar'); var utils = require('web.utils'); @@ -57,7 +58,7 @@ publicWidget.registry.AccountPortalSidebar = PortalSidebar.extend({ if (!$target.length) { return; } - $('html, body').scrollTop($target.offset().top); + dom.scrollTo($target[0], {duration: 0}); }, /** * @private diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js index a0a761f4d8c..134efbf6097 100644 --- a/addons/portal/static/src/js/portal_chatter.js +++ b/addons/portal/static/src/js/portal_chatter.js @@ -2,6 +2,7 @@ odoo.define('portal.chatter', function (require) { 'use strict'; var core = require('web.core'); +const dom = require('web.dom'); var publicWidget = require('web.public.widget'); var time = require('web.time'); var portalComposer = require('portal.composer'); @@ -292,17 +293,15 @@ publicWidget.registry.portalChatter = publicWidget.Widget.extend({ /** * @override */ - start: function () { - var self = this; - var defs = [this._super.apply(this, arguments)]; - var chatter = new PortalChatter(this, this.$el.data()); - defs.push(chatter.appendTo(this.$el)); - return Promise.all(defs).then(function () { - // scroll to the right place after chatter loaded - if (window.location.hash === '#' + self.$el.attr('id')) { - $('html, body').scrollTop(self.$el.offset().top); - } - }); + async start() { + const proms = [this._super.apply(this, arguments)]; + const chatter = new PortalChatter(this, this.$el.data()); + proms.push(chatter.appendTo(this.$el)); + await Promise.all(proms); + // scroll to the right place after chatter loaded + if (window.location.hash === `#${this.el.id}`) { + dom.scrollTo(this.el, {duration: 0}); + } }, }); diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 716593eb558..f4aba50be8a 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -141,6 +141,12 @@ var dom = { }); } }, + /** + * @return {HTMLElement} + */ + closestScrollable(el) { + return $(el).closestScrollable()[0]; + }, /** * jQuery find function behavior is:: * @@ -228,6 +234,26 @@ var dom = { } return position; }, + /** + * @returns {HTMLElement} + */ + getScrollingElement() { + return $().getScrollingElement()[0]; + }, + /** + * @param {HTMLElement} el + * @returns {boolean} + */ + hasScrollableContent(el) { + return $(el).hasScrollableContent(); + }, + /** + * @param {HTMLElement} el + * @returns {boolean} + */ + isScrollable(el) { + return $(el).isScrollable(); + }, /** * Protects a function which is to be used as a handler by preventing its * execution for the duration of a previous call to it (including async @@ -454,6 +480,81 @@ var dom = { .select(); } }, + /** + * Computes the size by which a scrolling point should be decreased so that + * the top fixed elements of the page appear above that scrolling point. + * + * @returns {number} + */ + scrollFixedOffset() { + let size = 0; + for (const el of $('.o_top_fixed_element')) { + size += $(el).outerHeight(); + } + return size; + }, + /** + * @param {HTMLElement} el - the element to stroll to + * @param {number} [options] - same as animate of jQuery + * @param {number} [options.extraOffset=0] + * extra offset to add on top of the automatic one (the automatic one + * being computed based on fixed header sizes) + * @param {number} [options.forcedOffset] + * offset used instead of the automatic one (extraOffset will be + * ignored too) + * @return {Promise} + */ + scrollTo(el, options = {}) { + const $el = $(el); + const $scrollable = $el.parent().closestScrollable(); + const $topLevelScrollable = $().getScrollingElement(); + const isTopScroll = $scrollable.is($topLevelScrollable); + + function _computeScrollTop() { + const elPosition = $scrollable[0].scrollTop + ($el.offset().top - $scrollable.offset().top); + let offset = options.forcedOffset; + if (offset === undefined) { + offset = (isTopScroll ? dom.scrollFixedOffset() : 0) + (options.extraOffset || 0); + } + return Math.max(0, elPosition - offset); + } + + const originalScrollTop = _computeScrollTop(); + + return new Promise(resolve => { + const clonedOptions = Object.assign({}, options); + + // During the animation, detect any change needed for the scroll + // offset. If any occurs, stop the animation and continuing it to + // the new scroll point for the remaining time. + // Note: limitation, the animation won't be as fluid as possible if + // the easing mode is different of 'linear'. + clonedOptions.progress = function (a, b, remainingMs) { + if (options.progress) { + options.progress.apply(this, ...arguments); + } + const newScrollTop = _computeScrollTop(); + if (Math.abs(newScrollTop - originalScrollTop) <= 1.0) { + return; + } + $scrollable.stop(); + dom.scrollTo(el, Object.assign({}, options, { + duration: remainingMs, + })).then(() => resolve()); + }; + + // Detect the end of the animation to be able to indicate it to + // the caller via the returned Promise. + clonedOptions.complete = function () { + if (options.complete) { + options.complete.apply(this, ...arguments); + } + resolve(); + }; + + $scrollable.animate({scrollTop: originalScrollTop}, clonedOptions); + }); + }, /** * Creates an automatic 'more' dropdown-menu for a set of navbar items. * diff --git a/addons/web/static/src/js/libs/bootstrap.js b/addons/web/static/src/js/libs/bootstrap.js index 8f200794fa8..07e04c64a39 100644 --- a/addons/web/static/src/js/libs/bootstrap.js +++ b/addons/web/static/src/js/libs/bootstrap.js @@ -82,6 +82,20 @@ $.fn.tooltip.Constructor.prototype.show = function () { return bootstrapShowFunction.call(this); }; +/* Bootstrap scrollspy fix for non-body to spy */ + +const bootstrapSpyRefreshFunction = $.fn.scrollspy.Constructor.prototype.refresh; +$.fn.scrollspy.Constructor.prototype.refresh = function () { + bootstrapSpyRefreshFunction.apply(this, arguments); + if (this._scrollElement === window || this._config.method !== 'offset') { + return; + } + const baseScrollTop = this._getScrollTop(); + for (let i = 0; i < this._offsets.length; i++) { + this._offsets[i] += baseScrollTop; + } +}; + return { makeExtendedSanitizeWhiteList: makeExtendedSanitizeWhiteList, }; diff --git a/addons/web/static/src/js/libs/jquery.js b/addons/web/static/src/js/libs/jquery.js index 1a33d43c2e5..6921b004019 100644 --- a/addons/web/static/src/js/libs/jquery.js +++ b/addons/web/static/src/js/libs/jquery.js @@ -122,5 +122,92 @@ $.fn.extend({ }); }); }, + /** + * @return {jQuery} + */ + closestScrollable() { + let $el = this; + while ($el[0] !== document.scrollingElement) { + if ($el.isScrollable()) { + return $el; + } + $el = $el.parent(); + } + return $el; + }, + /** + * @returns {jQuery} + */ + getScrollingElement() { + const $baseScrollingElement = $(document.scrollingElement); + if ($baseScrollingElement.isScrollable() + && $baseScrollingElement.hasScrollableContent()) { + return $baseScrollingElement; + } + const bodyHeight = $(document.body).height(); + for (const el of document.body.children) { + // Search for a body child which is at least as tall as the body + // and which has the ability to scroll if enough content in it. If + // found, suppose this is the top scrolling element. + if (el.scrollHeight < bodyHeight) { + continue; + } + const $el = $(el); + if ($el.isScrollable()) { + return $el; + } + } + return $baseScrollingElement; + }, + /** + * @return {boolean} + */ + hasScrollableContent() { + return this[0].scrollHeight > this[0].clientHeight; + }, + /** + * @returns {boolean} + */ + isScrollable() { + const overflow = this.css('overflow-y'); + return overflow === 'auto' || overflow === 'scroll' + || (overflow === 'visible' && this === document.scrollingElement); + }, }); + +// jQuery functions monkey-patching + +// Some magic to ensure scrolltop and animate on html/body animate the top level +// scrollable element even if not html or body. +const originalScrollTop = $.fn.scrollTop; +$.fn.scrollTop = function (value) { + if (value !== undefined && this.filter('html, body').length) { + // The caller wants to scroll a set of elements including html and/or + // body to a specific point -> do that but make sure to add the real + // top level element to that set of elements if any different is found. + originalScrollTop.apply(this.not('html, body').add($().getScrollingElement()), arguments); + return this; + } else if (value === undefined && this.eq(0).is('html, body')) { + // The caller wants to get the scroll point of a set of elements, jQuery + // will return the scroll point of the first one, if it is html or body + // return the scroll point of the real top level element. + return originalScrollTop.apply($().getScrollingElement(), arguments); + } + return originalScrollTop.apply(this, arguments); +}; +const originalAnimate = $.fn.animate; +$.fn.animate = function (properties, ...rest) { + const props = Object.assign({}, properties); + if ('scrollTop' in props && this.filter('html, body').length) { + // The caller wants to scroll a set of elements including html and/or + // body to a specific point -> do that but make sure to add the real + // top level element to that set of elements if any different is found. + originalAnimate.call(this.not('html, body').add($().getScrollingElement()), {'scrollTop': props['scrollTop']}, ...rest); + delete props['scrollTop']; + } + if (!Object.keys(props).length) { + return this; + } + return originalAnimate.call(this, props, ...rest); +}; }); diff --git a/addons/web/static/src/scss/base_frontend.scss b/addons/web/static/src/scss/base_frontend.scss index f1c341a89f9..585ce965ec5 100644 --- a/addons/web/static/src/scss/base_frontend.scss +++ b/addons/web/static/src/scss/base_frontend.scss @@ -1,4 +1,9 @@ // Frontend general +html, body, #wrapwrap { + width: 100%; + height: 100%; + overflow: hidden; +} #wrapwrap { // The z-index is useful to prevent that children with a negative z-index // go behind the wrapwrap (we create a new stacking context). @@ -6,8 +11,11 @@ position: relative; display: flex; flex-flow: column nowrap; - width: 100%; - min-height: 100%; + // ... we delegate the scroll to that top element instead of the window/body + // This is at least needed for the edit mode to not have a double scrollbar + // due to the right editor panel (and since we want to minimize the style + // difference between edit mode and non-edit mode (wysiwyg)...). + overflow: auto; > * { flex: 0 0 auto; diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index aa5bdd867e9..ecebf152f9d 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -100,7 +100,7 @@ var SnippetEditor = Widget.extend({ }, }, }); - this.draggableComponent = new SmoothScrollOnDrag(this, this.$el, $('html'), smoothScrollOptions); + this.draggableComponent = new SmoothScrollOnDrag(this, this.$el, $().getScrollingElement(), smoothScrollOptions); } else { this.$('.o_overlay_move_options').addClass('d-none'); $customize.find('.oe_snippet_clone').addClass('d-none'); @@ -997,8 +997,8 @@ var SnippetsMenu = Widget.extend({ // Hide the active overlay when scrolling. // Show it again and recompute all the overlays after the scroll. - // TODO: current limitation, suppose that this is the body which scrolls - this.$document.on('scroll.snippets_menu', _.throttle(() => { + this.$scrollingElement = $().getScrollingElement(); + this.$scrollingElement.on('scroll.snippets_menu', _.throttle(() => { for (const editor of this.snippetEditors) { editor.toggleOverlayVisibility(false); } @@ -1054,6 +1054,7 @@ var SnippetsMenu = Widget.extend({ this.$snippetEditorArea.remove(); this.$window.off('.snippets_menu'); this.$document.off('.snippets_menu'); + this.$scrollingElement.off('.snippets_menu'); } core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet); delete this.cacheSnippetTemplate[this.options.snippets]; @@ -1778,13 +1779,6 @@ var SnippetsMenu = Widget.extend({ */ _getScrollOptions(options = {}) { return Object.assign({}, options, { - offsetElements: Object.assign({ - $top: $('#web_editor-top-edit'), // TODO should ideally be retrieved another way - $left: this.$el, - }, options.offsetElements), - scrollBoundaries: Object.assign({ - left: false, - }, options.scrollBoundaries), jQueryDraggableOptions: Object.assign({ appendTo: this.$body, cursor: 'move', @@ -1954,7 +1948,7 @@ var SnippetsMenu = Widget.extend({ }, }, }); - this.draggableComponent = new SmoothScrollOnDrag(this, $snippets, $('html'), smoothScrollOptions); + this.draggableComponent = new SmoothScrollOnDrag(this, $snippets, $().getScrollingElement(), smoothScrollOptions); }, /** * Adds the 'o_default_snippet_text' class on nodes which contain only @@ -2005,29 +1999,14 @@ var SnippetsMenu = Widget.extend({ .prop('disabled', tab !== this.tabs.OPTIONS); }, /** - * Scroll to the dropped snippet. + * Scrolls to given snippet. * * @private - * @param {jQuery} [$el] - dropped snippet - * @param {integer} [scrollValue] - scrollValue + * @param {jQuery} $el - snippet to scroll to + * @return {Promise} */ - _scrollToSnippet: function ($el, scrollValue) { - // return if it's an inner snippet or if it's dropped in a modal - if ($el.get(0).tagName.toLowerCase() !== 'section' || $el.parent().find("[class^='modal-']").length) { - return Promise.resolve(); - } - let headerHeight = 0; - _.each($('.o_top_fixed_element'), el => headerHeight += $(el).outerHeight()); - return new Promise(resolve => { - $('html, body').animate( - { - scrollTop: scrollValue - headerHeight - 50, - }, - 700, - 'swing', - resolve, - ); - }); + async _scrollToSnippet($el) { + return dom.scrollTo($el[0], {extraOffset: 50}); }, /** * @private diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index 27000cfd48d..bcf9ebbaf78 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -76,10 +76,8 @@ const BaseAnimatedHeader = animations.Animation.extend({ this._updateMainPaddingTop(); this.el.classList.toggle('o_top_fixed_element', this.fixedHeader && this._isShown()); - const bottom = this.el.getBoundingClientRect().bottom - || (this.el.nextElementSibling.getBoundingClientRect().top + window.scrollY); for (const callback of extraMenuUpdateCallbacks) { - callback(bottom); + callback(); } }, /** @@ -130,6 +128,14 @@ const BaseAnimatedHeader = animations.Animation.extend({ _toggleFixedHeader: function (useFixed = true) { this.fixedHeader = useFixed; this.el.classList.toggle('o_header_affixed', useFixed); + // Compensate scrollbar + if (useFixed) { + const scrollableEl = this.$el.parent().closestScrollable()[0]; + const style = window.getComputedStyle(this.el); + this.el.style.setProperty('right', `${parseInt(style['right']) + scrollableEl.offsetWidth - scrollableEl.clientWidth}px`, 'important'); + } else { + this.el.style.removeProperty('right'); + } this._adaptToHeaderChange(); }, /** diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js index 81640cde197..368be2e810b 100644 --- a/addons/website/static/src/js/content/snippets.animation.js +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -8,6 +8,7 @@ odoo.define('website.content.snippets.animation', function (require) { var Class = require('web.Class'); var config = require('web.config'); var core = require('web.core'); +const dom = require('web.dom'); var mixins = require('web.mixins'); var publicWidget = require('web.public.widget'); var utils = require('web.utils'); @@ -123,12 +124,13 @@ var AnimationEffect = Class.extend(mixins.ParentedMixin, { // Initialize the animation startEvents, startTarget, endEvents, endTarget and callbacks this._updateCallback = updateCallback; this.startEvents = startEvents || 'scroll'; - this.$startTarget = $($startTarget || window); + this.$startTarget = $($startTarget ? $startTarget : this.startEvents === 'scroll' ? $().getScrollingElement()[0] : window); if (options.getStateCallback) { this._getStateCallback = options.getStateCallback; - } else if (this.startEvents === 'scroll' && this.$startTarget[0] === window) { + } else if (this.startEvents === 'scroll' && this.$startTarget[0] === $().getScrollingElement()[0]) { + const $scrollable = this.$startTarget; this._getStateCallback = function () { - return window.pageYOffset; + return $scrollable.scrollTop(); }; } else if (this.startEvents === 'resize' && this.$startTarget[0] === window) { this._getStateCallback = function () { @@ -898,38 +900,19 @@ registry.anchorSlide = publicWidget.Widget.extend({ * @private * @param {jQuery} $el the element to scroll to. * @param {string} [scrollValue='true'] scroll value + * @returns {Promise} */ - _scrollTo: function ($el, scrollValue = 'true', forcedDelay = undefined) { - const _computeScrollTo = () => { - const headerHeight = this._computeHeaderHeight(); - const offset = $el.css('position') !== 'fixed' ? $el.offset().top : $el.position().top; - return offset - headerHeight; - }; - const originalScrollTo = _computeScrollTo(); - return new Promise(resolve => { - $('html, body').animate({ - scrollTop: originalScrollTo, - }, { - duration: forcedDelay || (scrollValue === 'true' ? 500 : 0), - progress: (a, b, remainingMs) => { - const scrollTo = _computeScrollTo(); - if (Math.abs(scrollTo - originalScrollTo) >= 1.0) { - $('html, body').stop(); - this._scrollTo($el, scrollValue, remainingMs).then(() => resolve()); - } - }, - complete: () => resolve(), - }); + async _scrollTo($el, scrollValue = 'true') { + return dom.scrollTo($el[0], { + duration: scrollValue === 'true' ? 500 : 0, + extraOffset: this._computeExtraOffset(), }); }, /** * @private */ - _computeHeaderHeight: function () { - let headerHeight = 0; - const $navbarFixed = $('.o_top_fixed_element'); - _.each($navbarFixed, el => headerHeight += $(el).outerHeight()); - return headerHeight; + _computeExtraOffset() { + return 0; }, //-------------------------------------------------------------------------- diff --git a/addons/website/static/src/js/editor/snippets.editor.js b/addons/website/static/src/js/editor/snippets.editor.js index e226fd70543..40f999f836c 100644 --- a/addons/website/static/src/js/editor/snippets.editor.js +++ b/addons/website/static/src/js/editor/snippets.editor.js @@ -100,6 +100,7 @@ weSnippetEditor.Class.include({ if (!options.offsetElements || !options.offsetElements.$top) { const $header = $('#top'); if ($header.length) { + finalOptions.offsetElements = finalOptions.offsetElements || {}; finalOptions.offsetElements.$top = $header; } } diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 5819402c93a..3b53a594bd7 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -5,6 +5,7 @@ const {ColorpickerWidget} = require('web.Colorpicker'); const config = require('web.config'); var core = require('web.core'); var Dialog = require('web.Dialog'); +const dom = require('web.dom'); const weUtils = require('web_editor.utils'); var options = require('web_editor.snippets.options'); const wUtils = require('website.utils'); @@ -2504,13 +2505,10 @@ options.registry.SnippetMove = options.Class.extend({ break; } if (params.name === 'move_up_opt' || params.name === 'move_down_opt') { - $('html, body').animate( - { - scrollTop: this.$target.offset().top - $(window).height() / 2, - }, - 500, - 'linear', - ); + dom.scrollTo(this.$target[0], { + extraOffset: 50, + easing: 'linear', + }); } }, }); 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 index ac610304e48..68250bc88fa 100644 --- a/addons/website/static/src/snippets/s_table_of_content/000.js +++ b/addons/website/static/src/snippets/s_table_of_content/000.js @@ -11,18 +11,18 @@ const TableOfContent = publicWidget.Widget.extend({ /** * @override */ - start: function () { - this._initializeNavbarTopPosition(); + async start() { + await this._super(...arguments); + this._updateTableOfContentNavbarPosition(); extraMenuUpdateCallbacks.push(this._updateTableOfContentNavbarPosition.bind(this)); - return this._super.apply(this, arguments); }, /** * @override */ - destroy: function () { + destroy() { this.$target.css('top', ''); this.$target.find('.s_table_of_content_navbar').css('top', ''); - this._super.apply(this, arguments); + this._super(...arguments); }, //-------------------------------------------------------------------------- @@ -30,26 +30,16 @@ const TableOfContent = publicWidget.Widget.extend({ //-------------------------------------------------------------------------- /** - * Initialize the top position of the snippet navbar according to the height - * of the headers of the page. - * * @private */ - _initializeNavbarTopPosition: function () { - let headerHeight = 0; + _updateTableOfContentNavbarPosition() { + let position = 0; const $fixedElements = $('.o_top_fixed_element'); - _.each($fixedElements, el => headerHeight += $(el).outerHeight()); - this._updateTableOfContentNavbarPosition(headerHeight); - }, - /** - * @private - * @param {number} position - */ - _updateTableOfContentNavbarPosition: function (position) { + _.each($fixedElements, el => position += $(el).outerHeight()); const isHorizontalNavbar = this.$target.hasClass('s_table_of_content_horizontal_navbar'); this.$target.css('top', isHorizontalNavbar ? position : ''); this.$target.find('.s_table_of_content_navbar').css('top', isHorizontalNavbar ? '' : position + 20); - $('body').scrollspy({target: '.s_table_of_content_navbar', offset: position + 100}); + $().getScrollingElement().scrollspy({target: '.s_table_of_content_navbar', method: 'offset', offset: position + 100}); }, }); @@ -66,15 +56,15 @@ publicWidget.registry.anchorSlide.include({ * @override * @private */ - _computeHeaderHeight: function () { - let headerHeight = this._super(...arguments); + _computeExtraOffset() { + let extraOffset = 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(); + extraOffset += $(tableOfContentNavbarEl).outerHeight(); } } - return headerHeight; + return extraOffset; }, }); diff --git a/addons/website/views/website_navbar_templates.xml b/addons/website/views/website_navbar_templates.xml index 6537ed5e646..1126a8aaf5a 100644 --- a/addons/website/views/website_navbar_templates.xml +++ b/addons/website/views/website_navbar_templates.xml @@ -20,7 +20,7 @@ -