From a34cd7c662b3753d630d83793ffcb53b4a0a92d2 Mon Sep 17 00:00:00 2001 From: "Okan SUMER (osu)" Date: Tue, 10 Mar 2020 08:59:37 +0000 Subject: [PATCH] [IMP] web_tour: add a scroll indicator + input event for contenteditable This commit places a tip to indicate the user to scroll when the anchor is out of viewport. Also now allow to wait that the user types on his keyboard before consuming a step when text modification is required on a contenteditable element. Part of https://github.com/odoo/odoo/pull/48678 task-2228694 closes odoo/odoo#48678 Signed-off-by: Quentin Smetz (qsm) Co-authored-by: qsm-odoo --- addons/web_tour/static/src/js/tip.js | 248 ++++++++++++++---- addons/web_tour/static/src/scss/tip.scss | 4 +- .../static/tests/tour_manager_tests.js | 5 + addons/website/static/src/js/tours/banner.js | 1 + 4 files changed, 212 insertions(+), 46 deletions(-) diff --git a/addons/web_tour/static/src/js/tip.js b/addons/web_tour/static/src/js/tip.js index 6ccdb7b387c..5156fada00a 100644 --- a/addons/web_tour/static/src/js/tip.js +++ b/addons/web_tour/static/src/js/tip.js @@ -13,7 +13,9 @@ var Tip = Widget.extend({ click: '_onTipClicked', mouseenter: '_onMouseEnter', mouseleave: '_onMouseLeave', + transitionend: '_onTransitionEnd', }, + /** * @param {Widget} parent * @param {Object} [info] description of the tip, containing the following keys: @@ -38,25 +40,26 @@ var Tip = Widget.extend({ x: 50, y: 50, }, + scrollContent: _t("Scroll to reach the next step."), }); this.position = { top: "50%", left: "50%", }; + this.initialPosition = this.info.position; + this.viewPortState = 'in'; + this._onAncestorScroll = _.throttle(this._onAncestorScroll, 50); }, /** * @param {jQuery} $anchor the node on which the tip should be placed */ - attach_to: function ($anchor) { - this.$anchor = $anchor; - this.$ideal_location = this._get_ideal_location(); + attach_to: async function ($anchor) { + this._setupAnchor($anchor); - var position = this.$ideal_location.css("position"); - if (position === "static" || position === "relative") { - this.$ideal_location.addClass("o_tooltip_parent"); - } - - return this.appendTo(this.$ideal_location); + // The body never needs to have the o_tooltip_parent class. It is a + // safe place to put the tip in the DOM at initialization and be able + // to compute its dimensions and reposition it if required. + return this.appendTo(document.body); }, start: function() { this.$tooltip_overlay = this.$(".o_tooltip_overlay"); @@ -66,6 +69,10 @@ var Tip = Widget.extend({ this.double_border_width = this.$el.outerWidth() - this.init_width; this.content_width = this.$tooltip_content.outerWidth(true); this.content_height = this.$tooltip_content.outerHeight(true); + this.$tooltip_content.html(this.info.scrollContent); + this.scrollContentWidth = this.$tooltip_content.outerWidth(true); + this.scrollContentHeight = this.$tooltip_content.outerHeight(true); + this.$tooltip_content.html(this.info.content); this.$window = $(window); this.$tooltip_content.css({ @@ -76,9 +83,10 @@ var Tip = Widget.extend({ _.each(this.info.event_handlers, (function(data) { this.$tooltip_content.on(data.event, data.selector, data.handler); }).bind(this)); - this._bind_anchor_events(); - this._reposition(); + this._bind_anchor_events(); + this._updatePosition(true); + this.$el.css("opacity", 1); core.bus.on("resize", this, _.debounce(function () { if (this.tip_opened) { @@ -87,14 +95,6 @@ var Tip = Widget.extend({ this._reposition(); }, 500)); - this.$el.on("transitionend oTransitionEnd webkitTransitionEnd", (function () { - if (!this.tip_opened && this.$el.parent()[0] === document.body) { - this.$el.detach(); - this.$el.css(this.position); - this.$el.appendTo(this.$ideal_location); - } - }).bind(this)); - return this._super.apply(this, arguments); }, destroy: function () { @@ -103,26 +103,119 @@ var Tip = Widget.extend({ clearTimeout(this.timerOut); // Do not remove the parent class if it contains other tooltips - if (this.$ideal_location.children(".o_tooltip").not(this.$el[0]).length === 0) { - this.$ideal_location.removeClass("o_tooltip_parent"); - } + const _removeParentClass = $el => { + if ($el.children(".o_tooltip").not(this.$el[0]).length === 0) { + $el.removeClass("o_tooltip_parent"); + } + }; + _removeParentClass(this.$ideal_location); + _removeParentClass(this.$furtherIdealLocation); return this._super.apply(this, arguments); }, update: function ($anchor) { if (!$anchor.is(this.$anchor)) { this._unbind_anchor_events(); - this.$anchor = $anchor; - this.$ideal_location = this._get_ideal_location(); - if (this.$el.parent()[0] !== document.body) { - this.$el.appendTo(this.$ideal_location); - } + this._setupAnchor($anchor); this._bind_anchor_events(); } - this._reposition(); + if (!this.$el) { + // Ideally this case should not happen but this is still possible, + // as update may be called before the `start` method is called. + // The `start` method is calling _updatePosition too anyway. + return; + } + this._updatePosition(true); }, - _get_ideal_location: function () { - var $location = this.$anchor; + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQuery} $anchor + */ + _setupAnchor: function ($anchor) { + this.$anchor = $anchor; + this.$actualAnchor = this.$anchor; + this.$ideal_location = this._get_ideal_location(); + this.$furtherIdealLocation = this._get_ideal_location(this.$ideal_location); + }, + /** + * Figures out which direction the tip should take and if it is at the + * bottom or the top of the targeted element or if it's an indicator to + * scroll. Relocates and repositions if necessary. + * + * @private + * @param {boolean} [forceReposition=false] + */ + _updatePosition: function (forceReposition = false) { + let halfHeight = 0; + if (this.initialPosition === 'right' || this.initialPosition === 'left') { + halfHeight = this.$anchor.innerHeight() / 2; + } + + const paddingTop = parseInt(this.$ideal_location.css('padding-top')); + const topViewport = window.pageYOffset + paddingTop; + const botViewport = window.pageYOffset + window.innerHeight; + const topOffset = this.$anchor.offset().top; + const botOffset = topOffset + this.$anchor.innerHeight(); + + // Check if the viewport state change to know if we need to move the anchor of the tip. + // up : the target element is above the current viewport + // down : the target element is below the current viewport + // in : the target element is in the current viewport + let viewPortState = 'in'; + let position = this.info.position; + if (botOffset - halfHeight < topViewport) { + viewPortState = 'up'; + position = 'bottom'; + } else if (topOffset + halfHeight > botViewport) { + viewPortState = 'down'; + position = 'top'; + } else { + // Adjust the placement of the tip regarding its anchor depending + // if we came from the bottom or the top. + if (topOffset < topViewport + this.$el.innerHeight()) { + position = halfHeight ? this.initialPosition : "bottom"; + } else if (botOffset > botViewport - this.$el.innerHeight()) { + position = halfHeight ? this.initialPosition : "top"; + } + } + + // If the direction or the anchor change : The tip position is updated. + if (forceReposition || this.info.position !== position || this.viewPortState !== viewPortState) { + this.$el.removeClass('top right bottom left').addClass(position); + this.viewPortState = viewPortState; + this.info.position = position; + let $location; + if (this.viewPortState === 'in') { + this.$tooltip_content.html(this.info.content); + this.$actualAnchor = this.$anchor; + $location = this.$ideal_location; + } else { + this.$tooltip_content.html(this.info.scrollContent); + this.$actualAnchor = this.$ideal_location; + $location = this.$furtherIdealLocation; + } + // Update o_tooltip_parent class and tip DOM location. Note: + // important to only remove/add the class when necessary to not + // notify a DOM mutation which could retrigger this function. + const $oldLocation = this.$el.parent(); + if (!$location.is($oldLocation)) { + $oldLocation.removeClass('o_tooltip_parent'); + const cssPosition = $location.css("position"); + if (cssPosition === "static" || cssPosition === "relative") { + $location.addClass("o_tooltip_parent"); + } + this.$el.appendTo($location); + } + this._reposition(); + } + }, + _get_ideal_location: function ($anchor = this.$anchor) { + var $location = $anchor; if ($location.is("html,body")) { return $(document.body); } @@ -146,8 +239,6 @@ var Tip = Widget.extend({ return $location; }, _reposition: function () { - if (this.tip_opened) return; - if (!this.$el) return; this.$el.removeClass("o_animated"); // Reverse left/right position if direction is right to left @@ -156,36 +247,64 @@ var Tip = Widget.extend({ if (rtlMap[appendAt] && _t.database.parameters.direction === 'rtl') { appendAt = rtlMap[appendAt]; } - this.$el.position({ - my: this._get_spaced_inverted_position(appendAt), - at: appendAt, - of: this.$anchor, - collision: "none", - }); + + // Get the correct tip's position depending of the tip's state + let $parent = this.$ideal_location; + if ($parent.is('html,body') && this.viewPortState !== "in") { + this.$el.css({position: 'fixed'}); + } else { + this.$el.css({position: ''}); + } + + if (this.viewPortState === 'in') { + this.$el.position({ + my: this._get_spaced_inverted_position(appendAt), + at: appendAt, + of: this.$anchor, + collision: "none", + }); + } else { + const paddingTop = parseInt($parent.css('padding-top')); + const paddingLeft = parseInt($parent.css('padding-left')); + const paddingRight = parseInt($parent.css('padding-right')); + const topPosition = $parent[0].offsetTop; + const center = (paddingLeft + paddingRight) + ((($parent[0].clientWidth - (paddingLeft + paddingRight)) / 2) - this.$el[0].offsetWidth / 2); + let top; + if (this.viewPortState === 'up') { + top = topPosition + this.$el.innerHeight() + paddingTop; + } else { + top = topPosition + $parent.innerHeight() - this.$el.innerHeight() * 2; + } + this.$el.css({top: top, left: center}); + } // Reverse overlay if direction is right to left var positionRight = _t.database.parameters.direction === 'rtl' ? "right" : "left"; var positionLeft = _t.database.parameters.direction === 'rtl' ? "left" : "right"; - var offset = this.$el.offset(); + + // get the offset position of this.$el + // Couldn't use offset() or position() because their values are not the desired ones in all cases + const offset = {top: this.$el[0].offsetTop, left: this.$el[0].offsetLeft}; this.$tooltip_overlay.css({ top: -Math.min((this.info.position === "bottom" ? this.info.space : this.info.overlay.y), offset.top), right: -Math.min((this.info.position === positionRight ? this.info.space : this.info.overlay.x), this.$window.width() - (offset.left + this.init_width + this.double_border_width)), bottom: -Math.min((this.info.position === "top" ? this.info.space : this.info.overlay.y), this.$window.height() - (offset.top + this.init_height + this.double_border_width)), left: -Math.min((this.info.position === positionLeft ? this.info.space : this.info.overlay.x), offset.left), }); - - this.position = this.$el.position(); + this.position = offset; this.$el.addClass("o_animated"); }, _bind_anchor_events: function () { - this.consume_event = Tip.getConsumeEventType(this.$anchor, this.info.run); + this.consume_event = this.info.consumeEvent || Tip.getConsumeEventType(this.$anchor, this.info.run); this.$consumeEventAnchor = this.$anchor; if (this.consume_event === "drag") { // jQuery-ui draggable triggers 'drag' events on the .ui-draggable element, // but the tip is attached to the .ui-draggable-handle element which may // be one of its children (or the element itself) this.$consumeEventAnchor = this.$anchor.closest('.ui-draggable'); + } else if (this.consume_event === "input" && !this.$anchor.is('textarea, input')) { + this.$consumeEventAnchor = this.$anchor.closest("[contenteditable='true']"); } else if (this.consume_event.includes('apply.daterangepicker')) { this.$consumeEventAnchor = this.$anchor.parent().children('.o_field_date_range'); } else if (this.consume_event === "sort") { @@ -201,10 +320,14 @@ var Tip = Widget.extend({ }).bind(this)); this.$anchor.on('mouseenter.anchor', () => this._to_info_mode()); this.$anchor.on('mouseleave.anchor', () => this._to_bubble_mode()); + + this.$scrolableElement = this.$ideal_location.is('html,body') ? $(window) : this.$ideal_location; + this.$scrolableElement.on('scroll.Tip', () => this._onAncestorScroll()); }, _unbind_anchor_events: function () { this.$anchor.off(".anchor"); this.$consumeEventAnchor.off(".anchor"); + this.$scrolableElement.off('.Tip'); }, _get_spaced_inverted_position: function (position) { if (position === "right") return "left+" + this.info.space; @@ -236,6 +359,13 @@ var Tip = Widget.extend({ var offset = this.$el.offset(); + // When this.$el doesn't have any parents, it means that the tip is no + // longer in the DOM and so, it shouldn't be open. It happens when the + // tip is opened after being destroyed. + if (!this.$el.parent().length) { + return; + } + if (this.$el.parent()[0] !== document.body) { this.$el.detach(); this.$el.css(offset); @@ -258,14 +388,20 @@ var Tip = Widget.extend({ mbTop -= (this.content_height - this.init_height); } + + const [contentWidth, contentHeight] = this.viewPortState === 'in' + ? [this.content_width, this.content_height] + : [this.scrollContentWidth, this.scrollContentHeight]; this.$el.toggleClass("inverse", overflow); this.$el.removeClass("o_animated").addClass("active"); this.$el.css({ - width: this.content_width, - height: this.content_height, + width: contentWidth, + height: contentHeight, "margin-left": mbLeft, "margin-top": mbTop, }); + + this._transitionEndTimer = setTimeout(() => this._onTransitionEnd(), 400); }, _to_bubble_mode: function (force) { if (this.timerIn !== undefined) { @@ -295,12 +431,24 @@ var Tip = Widget.extend({ height: this.init_height, margin: 0, }); + + this._transitionEndTimer = setTimeout(() => this._onTransitionEnd(), 400); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- + /** + * @private + */ + _onAncestorScroll: function () { + if (this.tip_opened) { + this._to_bubble_mode(true); + } else { + this._updatePosition(); + } + }, /** * @private */ @@ -323,6 +471,18 @@ var Tip = Widget.extend({ this._to_bubble_mode(); } }, + /** + * @private + */ + _onTransitionEnd: function () { + if (this._transitionEndTimer) { + clearTimeout(this._transitionEndTimer); + this._transitionEndTimer = undefined; + if (!this.tip_opened) { + this._updatePosition(true); + } + } + }, }); /** diff --git a/addons/web_tour/static/src/scss/tip.scss b/addons/web_tour/static/src/scss/tip.scss index 07005a35099..b27a428e437 100644 --- a/addons/web_tour/static/src/scss/tip.scss +++ b/addons/web_tour/static/src/scss/tip.scss @@ -28,7 +28,7 @@ $o-tip-arrow-width: 20px; $o-tip-arrow-integration: 2px; - $o-tip-duration-in: 300ms; + $o-tip-duration-in: 200ms; $o-tip-size-duration-in: floor($o-tip-duration-in * 3 / 4); $o-tip-size-delay-in: floor($o-tip-duration-in / 4); $o-tip-border-radius-duration-in: floor($o-tip-duration-in / 2); @@ -40,7 +40,7 @@ $o-tip-white-arrow-duration-in: floor($o-tip-duration-in * 1); $o-tip-white-arrow-delay-in: floor($o-tip-duration-in * 0); - $o-tip-duration-out: 200ms; + $o-tip-duration-out: 150ms; $o-tip-size-duration-out: floor($o-tip-duration-out * 2 / 3); $o-tip-size-delay-out: floor($o-tip-duration-out * 0); $o-tip-border-radius-duration-out: floor($o-tip-duration-out * 2 / 3); diff --git a/addons/web_tour/static/tests/tour_manager_tests.js b/addons/web_tour/static/tests/tour_manager_tests.js index 61d9fefa219..79ce4c0ceb5 100644 --- a/addons/web_tour/static/tests/tour_manager_tests.js +++ b/addons/web_tour/static/tests/tour_manager_tests.js @@ -64,6 +64,11 @@ odoo.define('web_tour.tour_manager_tests', async function (require) { { name: "Tour 2", options: {}, steps: [{ trigger: '.anchor' }] }, { name: "Tour 3", options: { sequence: 5 }, steps: [{ trigger: '.anchor', content: "Oui" }] }, ], + // Use this test in "debug" mode because the tips need to be in + // the viewport to be able to test their normal content + // (otherwise, the tips would indicate to the users that they + // have to scroll). + debug: true, }); assert.containsOnce(document.body, '.o_tooltip'); diff --git a/addons/website/static/src/js/tours/banner.js b/addons/website/static/src/js/tours/banner.js index 04311eeecaf..112c4c64b6f 100644 --- a/addons/website/static/src/js/tours/banner.js +++ b/addons/website/static/src/js/tours/banner.js @@ -23,6 +23,7 @@ tour.register("banner", { trigger: "#wrapwrap .s_cover h1", content: _t("Click on a text to start editing it. It's that easy to edit your content!"), position: "bottom", + consumeEvent: 'input', run: "text", }, { trigger: ".o_we_customize_panel",