summaryrefslogtreecommitdiff
path: root/addons/web_tour/static/src/js/tip.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web_tour/static/src/js/tip.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web_tour/static/src/js/tip.js')
-rw-r--r--addons/web_tour/static/src/js/tip.js611
1 files changed, 611 insertions, 0 deletions
diff --git a/addons/web_tour/static/src/js/tip.js b/addons/web_tour/static/src/js/tip.js
new file mode 100644
index 00000000..f768f03f
--- /dev/null
+++ b/addons/web_tour/static/src/js/tip.js
@@ -0,0 +1,611 @@
+odoo.define('web_tour.Tip', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var Widget = require('web.Widget');
+var _t = core._t;
+
+var Tip = Widget.extend({
+ template: "Tip",
+ xmlDependencies: ['/web_tour/static/src/xml/tip.xml'],
+ events: {
+ click: '_onTipClicked',
+ mouseenter: '_onMouseEnter',
+ mouseleave: '_onMouseLeave',
+ transitionend: '_onTransitionEnd',
+ },
+
+ /**
+ * @param {Widget} parent
+ * @param {Object} [info] description of the tip, containing the following keys:
+ * - content [String] the html content of the tip
+ * - event_handlers [Object] description of optional event handlers to bind to the tip:
+ * - event [String] the event name
+ * - selector [String] the jQuery selector on which the event should be bound
+ * - handler [function] the handler
+ * - position [String] tip's position ('top', 'right', 'left' or 'bottom'), default 'right'
+ * - width [int] the width in px of the tip when opened, default 270
+ * - space [int] space in px between anchor and tip, default to 0, added to
+ * the natural space chosen in css
+ * - hidden [boolean] if true, the tip won't be visible (but the handlers will still be
+ * bound on the anchor, so that the tip is consumed if the user clicks on it)
+ * - overlay [Object] x and y values for the number of pixels the mouseout detection area
+ * overlaps the opened tip, default {x: 50, y: 50}
+ */
+ init: function(parent, info) {
+ this._super(parent);
+ this.info = _.defaults(info, {
+ position: "right",
+ width: 270,
+ space: 0,
+ overlay: {
+ 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);
+ },
+ /**
+ * Attaches the tip to the provided $anchor and $altAnchor.
+ * $altAnchor is an alternative trigger that can consume the step. The tip is
+ * however only displayed on the $anchor.
+ *
+ * Note that the returned promise stays pending if the Tip widget was
+ * destroyed in the meantime.
+ *
+ * @param {jQuery} $anchor the node on which the tip should be placed
+ * @param {jQuery} $altAnchor an alternative node that can consume the step
+ * @return {Promise}
+ */
+ attach_to: async function ($anchor, $altAnchor) {
+ this._setupAnchor($anchor, $altAnchor);
+
+ this.is_anchor_fixed_position = this.$anchor.css("position") === "fixed";
+
+ // 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.
+ await this.appendTo(document.body);
+ if (this.isDestroyed()) {
+ return new Promise(() => {});
+ }
+ },
+ start() {
+ this.$tooltip_overlay = this.$(".o_tooltip_overlay");
+ this.$tooltip_content = this.$(".o_tooltip_content");
+ this.init_width = this.$el.outerWidth();
+ this.init_height = this.$el.outerHeight();
+ this.double_border_width = 0; // TODO remove me in master
+ this.$el.addClass('active');
+ this.el.style.setProperty('width', `${this.info.width}px`, 'important');
+ this.el.style.setProperty('height', 'auto', 'important');
+ this.el.style.setProperty('transition', 'none', 'important');
+ this.content_width = this.$el.outerWidth(true);
+ this.content_height = this.$el.outerHeight(true);
+ this.$tooltip_content.html(this.info.scrollContent);
+ this.scrollContentWidth = this.$el.outerWidth(true);
+ this.scrollContentHeight = this.$el.outerHeight(true);
+ this.$el.removeClass('active');
+ this.el.style.removeProperty('width');
+ this.el.style.removeProperty('height');
+ this.el.style.removeProperty('transition');
+ this.$tooltip_content.html(this.info.content);
+ this.$window = $(window);
+
+ this.$tooltip_content.css({
+ width: "100%",
+ height: "100%",
+ });
+
+ _.each(this.info.event_handlers, data => {
+ this.$tooltip_content.on(data.event, data.selector, data.handler);
+ });
+
+ this._bind_anchor_events();
+ this._updatePosition(true);
+
+ this.$el.toggleClass('d-none', !!this.info.hidden);
+ this.el.classList.add('o_tooltip_visible');
+ core.bus.on("resize", this, _.debounce(function () {
+ if (this.tip_opened) {
+ this._to_bubble_mode(true);
+ } else {
+ this._reposition();
+ }
+ }, 500));
+
+ return this._super.apply(this, arguments);
+ },
+ destroy: function () {
+ this._unbind_anchor_events();
+ clearTimeout(this.timerIn);
+ clearTimeout(this.timerOut);
+ // clear this timeout so that we won't call _updatePosition after we
+ // destroy the widget and leave an undesired bubble.
+ clearTimeout(this._transitionEndTimer);
+
+ // Do not remove the parent class if it contains other tooltips
+ const _removeParentClass = $el => {
+ if ($el.children(".o_tooltip").not(this.$el[0]).length === 0) {
+ $el.removeClass("o_tooltip_parent");
+ }
+ };
+ if (this.$el && this.$ideal_location) {
+ _removeParentClass(this.$ideal_location);
+ }
+ if (this.$el && this.$furtherIdealLocation) {
+ _removeParentClass(this.$furtherIdealLocation);
+ }
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Updates the $anchor and $altAnchor the tip is attached to.
+ * $altAnchor is an alternative trigger that can consume the step. The tip is
+ * however only displayed on the $anchor.
+ *
+ * @param {jQuery} $anchor the node on which the tip should be placed
+ * @param {jQuery} $altAnchor an alternative node that can consume the step
+ */
+ update: function ($anchor, $altAnchor) {
+ // We unbind/rebind events on each update because we support widgets
+ // detaching and re-attaching nodes to their DOM element without keeping
+ // the initial event handlers, with said node being potential tip
+ // anchors (e.g. FieldMonetary > input element).
+ this._unbind_anchor_events();
+ if (!$anchor.is(this.$anchor)) {
+ this._setupAnchor($anchor, $altAnchor);
+ }
+ this._bind_anchor_events();
+ this._delegateEvents();
+ 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);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the $anchor and $altAnchor the tip is attached to.
+ * $altAnchor is an alternative trigger that can consume the step. The tip is
+ * however only displayed on the $anchor.
+ *
+ * @param {jQuery} $anchor the node on which the tip should be placed
+ * @param {jQuery} $altAnchor an alternative node that can consume the step
+ */
+ _setupAnchor: function ($anchor, $altAnchor) {
+ this.$anchor = $anchor;
+ this.$altAnchor = $altAnchor;
+ 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) {
+ if (this.info.hidden) {
+ return;
+ }
+ 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);
+ $location = this.$ideal_location;
+ } else {
+ this.$tooltip_content.html(this.info.scrollContent);
+ $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 (!this.tip_opened) {
+ 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 = this.info.location ? $(this.info.location) : $anchor;
+ if ($location.is("html,body")) {
+ return $(document.body);
+ }
+
+ var o;
+ var p;
+ do {
+ $location = $location.parent();
+ o = $location.css("overflow");
+ p = $location.css("position");
+ } while (
+ $location.hasClass('dropdown-menu') ||
+ $location.hasClass('o_notebook_headers') ||
+ (
+ (o === "visible" || o.includes("hidden")) && // Possible case where the overflow = "hidden auto"
+ p !== "fixed" &&
+ $location[0].tagName.toUpperCase() !== 'BODY'
+ )
+ );
+
+ return $location;
+ },
+ _reposition: function () {
+ this.$el.removeClass("o_animated");
+
+ // Reverse left/right position if direction is right to left
+ var appendAt = this.info.position;
+ var rtlMap = {left: 'right', right: 'left'};
+ if (rtlMap[appendAt] && _t.database.parameters.direction === 'rtl') {
+ appendAt = rtlMap[appendAt];
+ }
+
+ // 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.style.setProperty('position', 'fixed', 'important');
+ } else {
+ this.el.style.removeProperty('position');
+ }
+
+ if (this.viewPortState === 'in') {
+ this.$el.position({
+ my: this._get_spaced_inverted_position(appendAt),
+ at: appendAt,
+ of: this.$anchor,
+ collision: "none",
+ using: props => {
+ this.el.style.setProperty('top', `${props.top}px`, 'important');
+ this.el.style.setProperty('left', `${props.left}px`, 'important');
+ },
+ });
+ } 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.style.setProperty('top', `${top}px`, 'important');
+ this.el.style.setProperty('left', `${center}px`, 'important');
+ }
+
+ // 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";
+
+ // 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)),
+ bottom: -Math.min((this.info.position === "top" ? this.info.space : this.info.overlay.y), this.$window.height() - (offset.top + this.init_height)),
+ left: -Math.min((this.info.position === positionLeft ? this.info.space : this.info.overlay.x), offset.left),
+ });
+ this.position = offset;
+
+ this.$el.addClass("o_animated");
+ },
+ _bind_anchor_events: function () {
+ // The consume_event taken for RunningTourActionHelper is the one of $anchor and not $altAnchor.
+ this.consume_event = this.info.consumeEvent || Tip.getConsumeEventType(this.$anchor, this.info.run);
+ this.$consumeEventAnchors = this._getAnchorAndCreateEvent(this.consume_event, this.$anchor);
+ if (this.$altAnchor.length) {
+ const consumeEvent = this.info.consumeEvent || Tip.getConsumeEventType(this.$altAnchor, this.info.run);
+ this.$consumeEventAnchors = this.$consumeEventAnchors.add(
+ this._getAnchorAndCreateEvent(consumeEvent, this.$altAnchor)
+ );
+ }
+ 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());
+ },
+ /**
+ * Gets the anchor corresponding to the provided arguments and attaches the
+ * event to the $anchor in order to consume the step accordingly.
+ *
+ * @private
+ * @param {String} consumeEvent
+ * @param {jQuery} $anchor the node on which the tip should be placed
+ * @return {jQuery}
+ */
+ _getAnchorAndCreateEvent: function(consumeEvent, $anchor) {
+ let $consumeEventAnchors = $anchor;
+ if (consumeEvent === "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)
+ $consumeEventAnchors = $anchor.closest('.ui-draggable');
+ } else if (consumeEvent === "input" && !$anchor.is('textarea, input')) {
+ $consumeEventAnchors = $anchor.closest("[contenteditable='true']");
+ } else if (consumeEvent.includes('apply.daterangepicker')) {
+ $consumeEventAnchors = $anchor.parent().children('.o_field_date_range');
+ } else if (consumeEvent === "sort") {
+ // when an element is dragged inside a sortable container (with classname
+ // 'ui-sortable'), jQuery triggers the 'sort' event on the container
+ $consumeEventAnchors = $anchor.closest('.ui-sortable');
+ }
+ $consumeEventAnchors.on(consumeEvent + ".anchor", (function (e) {
+ if (e.type !== "mousedown" || e.which === 1) { // only left click
+ this.trigger("tip_consumed");
+ this._unbind_anchor_events();
+ }
+ }).bind(this));
+ return $consumeEventAnchors;
+ },
+ _unbind_anchor_events: function () {
+ if (this.$anchor) {
+ this.$anchor.off(".anchor");
+ }
+ if (this.$consumeEventAnchors) {
+ this.$consumeEventAnchors.off(".anchor");
+ }
+ if (this.$scrolableElement) {
+ this.$scrolableElement.off('.Tip');
+ }
+ },
+ _get_spaced_inverted_position: function (position) {
+ if (position === "right") return "left+" + this.info.space;
+ if (position === "left") return "right-" + this.info.space;
+ if (position === "bottom") return "top+" + this.info.space;
+ return "bottom-" + this.info.space;
+ },
+ _to_info_mode: function (force) {
+ if (this.timerOut !== undefined) {
+ clearTimeout(this.timerOut);
+ this.timerOut = undefined;
+ return;
+ }
+ if (this.tip_opened) {
+ return;
+ }
+
+ if (force === true) {
+ this._build_info_mode();
+ } else {
+ this.timerIn = setTimeout(this._build_info_mode.bind(this), 100);
+ }
+ },
+ _build_info_mode: function () {
+ clearTimeout(this.timerIn);
+ this.timerIn = undefined;
+
+ this.tip_opened = true;
+
+ 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] !== this.$el[0].ownerDocument.body) {
+ this.$el.detach();
+ this.el.style.setProperty('top', `${offset.top}px`, 'important');
+ this.el.style.setProperty('left', `${offset.left}px`, 'important');
+ this.$el.appendTo(this.$el[0].ownerDocument.body);
+ }
+
+ var mbLeft = 0;
+ var mbTop = 0;
+ var overflow = false;
+ var posVertical = (this.info.position === "top" || this.info.position === "bottom");
+ if (posVertical) {
+ overflow = (offset.left + this.content_width + this.info.overlay.x > this.$window.width());
+ } else {
+ overflow = (offset.top + this.content_height + this.info.overlay.y > this.$window.height());
+ }
+ if (posVertical && overflow || this.info.position === "left" || (_t.database.parameters.direction === 'rtl' && this.info.position == "right")) {
+ mbLeft -= (this.content_width - this.init_width);
+ }
+ if (!posVertical && overflow || this.info.position === "top") {
+ mbTop -= (this.viewPortState === 'down') ? this.init_height - 5 : (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.style.setProperty('width', `${contentWidth}px`, 'important');
+ this.el.style.setProperty('height', `${contentHeight}px`, 'important');
+ this.el.style.setProperty('margin-left', `${mbLeft}px`, 'important');
+ this.el.style.setProperty('margin-top', `${mbTop}px`, 'important');
+
+ this._transitionEndTimer = setTimeout(() => this._onTransitionEnd(), 400);
+ },
+ _to_bubble_mode: function (force) {
+ if (this.timerIn !== undefined) {
+ clearTimeout(this.timerIn);
+ this.timerIn = undefined;
+ return;
+ }
+ if (!this.tip_opened) {
+ return;
+ }
+
+ if (force === true) {
+ this._build_bubble_mode();
+ } else {
+ this.timerOut = setTimeout(this._build_bubble_mode.bind(this), 300);
+ }
+ },
+ _build_bubble_mode: function () {
+ clearTimeout(this.timerOut);
+ this.timerOut = undefined;
+
+ this.tip_opened = false;
+ this.$el.removeClass("active").addClass("o_animated");
+ this.el.style.setProperty('width', `${this.init_width}px`, 'important');
+ this.el.style.setProperty('height', `${this.init_height}px`, 'important');
+ this.el.style.setProperty('margin', '0', 'important');
+
+ this._transitionEndTimer = setTimeout(() => this._onTransitionEnd(), 400);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAncestorScroll: function () {
+ if (this.tip_opened) {
+ this._to_bubble_mode(true);
+ } else {
+ this._updatePosition();
+ }
+ },
+ /**
+ * @private
+ */
+ _onMouseEnter: function () {
+ this._to_info_mode();
+ },
+ /**
+ * @private
+ */
+ _onMouseLeave: function () {
+ this._to_bubble_mode();
+ },
+ /**
+ * On touch devices, closes the tip when clicked.
+ *
+ * Also stop propagation to avoid undesired behavior, such as the kanban
+ * quick create closing when the user clicks on the tooltip.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onTipClicked: function (ev) {
+ if (config.device.touch && this.tip_opened) {
+ this._to_bubble_mode();
+ }
+
+ ev.stopPropagation();
+ },
+ /**
+ * @private
+ */
+ _onTransitionEnd: function () {
+ if (this._transitionEndTimer) {
+ clearTimeout(this._transitionEndTimer);
+ this._transitionEndTimer = undefined;
+ if (!this.tip_opened) {
+ this._updatePosition(true);
+ }
+ }
+ },
+});
+
+/**
+ * @static
+ * @param {jQuery} $element
+ * @param {string} [run] the run parameter of the tip (only strings are useful)
+ */
+Tip.getConsumeEventType = function ($element, run) {
+ if ($element.hasClass('o_field_many2one') || $element.hasClass('o_field_many2manytags')) {
+ return 'autocompleteselect';
+ } else if ($element.is("textarea") || $element.filter("input").is(function () {
+ var type = $(this).attr("type");
+ return !type || !!type.match(/^(email|number|password|search|tel|text|url)$/);
+ })) {
+ // FieldDateRange triggers a special event when using the widget
+ if ($element.hasClass("o_field_date_range")) {
+ return "apply.daterangepicker input";
+ }
+ if (config.device.isMobile &&
+ $element.closest('.o_field_widget').is('.o_field_many2one, .o_field_many2many')) {
+ return "click";
+ }
+ return "input";
+ } else if ($element.hasClass('ui-draggable-handle')) {
+ return "drag";
+ } else if (typeof run === 'string' && run.indexOf('drag_and_drop') === 0) {
+ // this is a heuristic: the element has to be dragged and dropped but it
+ // doesn't have class 'ui-draggable-handle', so we check if it has an
+ // ui-sortable parent, and if so, we conclude that its event type is 'sort'
+ if ($element.closest('.ui-sortable').length) {
+ return 'sort';
+ }
+ }
+ return "click";
+};
+
+return Tip;
+
+});