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",