diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web_tour/static/src/js/tip.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (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.js | 611 |
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; + +}); |
