/** * @license * Copyright 2011 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Library to create tooltips for Blockly. * First, call Blockly.Tooltip.init() after onload. * Second, set the 'tooltip' property on any SVG element that needs a tooltip. * If the tooltip is a string, then that message will be displayed. * If the tooltip is an SVG element, then that object's tooltip will be used. * Third, call Blockly.Tooltip.bindMouseEvents(e) passing the SVG element. * @author fraser@google.com (Neil Fraser) */ 'use strict'; /** * @name Blockly.Tooltip * @namespace */ goog.provide('Blockly.Tooltip'); goog.require('Blockly.browserEvents'); goog.require('Blockly.utils.string'); /** * A type which can define a tooltip. * Either a string, an object containing a tooltip property, or a function which * returns either a string, or another arbitrarily nested function which * eventually unwinds to a string. * @typedef {string|{tooltip}|function(): (string|!Function)} */ Blockly.Tooltip.TipInfo; /** * Is a tooltip currently showing? */ Blockly.Tooltip.visible = false; /** * Is someone else blocking the tooltip from being shown? * @type {boolean} * @private */ Blockly.Tooltip.blocked_ = false; /** * Maximum width (in characters) of a tooltip. */ Blockly.Tooltip.LIMIT = 50; /** * PID of suspended thread to clear tooltip on mouse out. * @private */ Blockly.Tooltip.mouseOutPid_ = 0; /** * PID of suspended thread to show the tooltip. * @private */ Blockly.Tooltip.showPid_ = 0; /** * Last observed X location of the mouse pointer (freezes when tooltip appears). * @private */ Blockly.Tooltip.lastX_ = 0; /** * Last observed Y location of the mouse pointer (freezes when tooltip appears). * @private */ Blockly.Tooltip.lastY_ = 0; /** * Current element being pointed at. * @type {Element} * @private */ Blockly.Tooltip.element_ = null; /** * Once a tooltip has opened for an element, that element is 'poisoned' and * cannot respawn a tooltip until the pointer moves over a different element. * @type {Element} * @private */ Blockly.Tooltip.poisonedElement_ = null; /** * Horizontal offset between mouse cursor and tooltip. */ Blockly.Tooltip.OFFSET_X = 0; /** * Vertical offset between mouse cursor and tooltip. */ Blockly.Tooltip.OFFSET_Y = 10; /** * Radius mouse can move before killing tooltip. */ Blockly.Tooltip.RADIUS_OK = 10; /** * Delay before tooltip appears. */ Blockly.Tooltip.HOVER_MS = 750; /** * Horizontal padding between tooltip and screen edge. */ Blockly.Tooltip.MARGINS = 5; /** * The HTML container. Set once by Blockly.Tooltip.createDom. * @type {Element} */ Blockly.Tooltip.DIV = null; /** * Returns the tooltip text for the given element. * @param {?Object} object The object to get the tooltip text of. * @returns {string} The tooltip text of the element. */ Blockly.Tooltip.getTooltipOfObject = function(object) { var obj = Blockly.Tooltip.getTargetObject_(object); if (obj) { var tooltip = obj.tooltip; while (typeof tooltip == 'function') { tooltip = tooltip(); } if (typeof tooltip != 'string') { throw Error('Tooltip function must return a string.'); } return tooltip; } return ''; }; /** * Returns the target object that the given object is targeting for its * tooltip. Could be the object itself. * @param {?Object} obj The object are trying to find the target tooltip * object of. * @returns {?{tooltip}} The target tooltip object. * @private */ Blockly.Tooltip.getTargetObject_ = function(obj) { while (obj && obj.tooltip) { if ((typeof obj.tooltip == 'string') || (typeof obj.tooltip == 'function')) { return obj; } obj = obj.tooltip; } return null; }; /** * Create the tooltip div and inject it onto the page. */ Blockly.Tooltip.createDom = function() { if (Blockly.Tooltip.DIV) { return; // Already created. } // Create an HTML container for popup overlays (e.g. editor widgets). Blockly.Tooltip.DIV = document.createElement('div'); Blockly.Tooltip.DIV.className = 'blocklyTooltipDiv'; var container = Blockly.parentContainer || document.body; container.appendChild(Blockly.Tooltip.DIV); }; /** * Binds the required mouse events onto an SVG element. * @param {!Element} element SVG element onto which tooltip is to be bound. */ Blockly.Tooltip.bindMouseEvents = function(element) { element.mouseOverWrapper_ = Blockly.browserEvents.bind( element, 'mouseover', null, Blockly.Tooltip.onMouseOver_); element.mouseOutWrapper_ = Blockly.browserEvents.bind( element, 'mouseout', null, Blockly.Tooltip.onMouseOut_); // Don't use bindEvent_ for mousemove since that would create a // corresponding touch handler, even though this only makes sense in the // context of a mouseover/mouseout. element.addEventListener('mousemove', Blockly.Tooltip.onMouseMove_, false); }; /** * Unbinds tooltip mouse events from the SVG element. * @param {!Element} element SVG element onto which tooltip is bound. */ Blockly.Tooltip.unbindMouseEvents = function(element) { if (!element) { return; } Blockly.browserEvents.unbind(element.mouseOverWrapper_); Blockly.browserEvents.unbind(element.mouseOutWrapper_); element.removeEventListener('mousemove', Blockly.Tooltip.onMouseMove_); }; /** * Hide the tooltip if the mouse is over a different object. * Initialize the tooltip to potentially appear for this object. * @param {!Event} e Mouse event. * @private */ Blockly.Tooltip.onMouseOver_ = function(e) { if (Blockly.Tooltip.blocked_) { // Someone doesn't want us to show tooltips. return; } // If the tooltip is an object, treat it as a pointer to the next object in // the chain to look at. Terminate when a string or function is found. var element = /** @type {Element} */ (Blockly.Tooltip.getTargetObject_( e.currentTarget)); if (Blockly.Tooltip.element_ != element) { Blockly.Tooltip.hide(); Blockly.Tooltip.poisonedElement_ = null; Blockly.Tooltip.element_ = element; } // Forget about any immediately preceding mouseOut event. clearTimeout(Blockly.Tooltip.mouseOutPid_); }; /** * Hide the tooltip if the mouse leaves the object and enters the workspace. * @param {!Event} _e Mouse event. * @private */ Blockly.Tooltip.onMouseOut_ = function(_e) { if (Blockly.Tooltip.blocked_) { // Someone doesn't want us to show tooltips. return; } // Moving from one element to another (overlapping or with no gap) generates // a mouseOut followed instantly by a mouseOver. Fork off the mouseOut // event and kill it if a mouseOver is received immediately. // This way the task only fully executes if mousing into the void. Blockly.Tooltip.mouseOutPid_ = setTimeout(function() { Blockly.Tooltip.element_ = null; Blockly.Tooltip.poisonedElement_ = null; Blockly.Tooltip.hide(); }, 1); clearTimeout(Blockly.Tooltip.showPid_); }; /** * When hovering over an element, schedule a tooltip to be shown. If a tooltip * is already visible, hide it if the mouse strays out of a certain radius. * @param {!Event} e Mouse event. * @private */ Blockly.Tooltip.onMouseMove_ = function(e) { if (!Blockly.Tooltip.element_ || !Blockly.Tooltip.element_.tooltip) { // No tooltip here to show. return; } else if (Blockly.Tooltip.blocked_) { // Someone doesn't want us to show tooltips. We are probably handling a // user gesture, such as a click or drag. return; } if (Blockly.Tooltip.visible) { // Compute the distance between the mouse position when the tooltip was // shown and the current mouse position. Pythagorean theorem. var dx = Blockly.Tooltip.lastX_ - e.pageX; var dy = Blockly.Tooltip.lastY_ - e.pageY; if (Math.sqrt(dx * dx + dy * dy) > Blockly.Tooltip.RADIUS_OK) { Blockly.Tooltip.hide(); } } else if (Blockly.Tooltip.poisonedElement_ != Blockly.Tooltip.element_) { // The mouse moved, clear any previously scheduled tooltip. clearTimeout(Blockly.Tooltip.showPid_); // Maybe this time the mouse will stay put. Schedule showing of tooltip. Blockly.Tooltip.lastX_ = e.pageX; Blockly.Tooltip.lastY_ = e.pageY; Blockly.Tooltip.showPid_ = setTimeout(Blockly.Tooltip.show_, Blockly.Tooltip.HOVER_MS); } }; /** * Dispose of the tooltip. * @package */ Blockly.Tooltip.dispose = function() { Blockly.Tooltip.element_ = null; Blockly.Tooltip.poisonedElement_ = null; Blockly.Tooltip.hide(); }; /** * Hide the tooltip. */ Blockly.Tooltip.hide = function() { if (Blockly.Tooltip.visible) { Blockly.Tooltip.visible = false; if (Blockly.Tooltip.DIV) { Blockly.Tooltip.DIV.style.display = 'none'; } } if (Blockly.Tooltip.showPid_) { clearTimeout(Blockly.Tooltip.showPid_); } }; /** * Hide any in-progress tooltips and block showing new tooltips until the next * call to unblock(). * @package */ Blockly.Tooltip.block = function() { Blockly.Tooltip.hide(); Blockly.Tooltip.blocked_ = true; }; /** * Unblock tooltips: allow them to be scheduled and shown according to their own * logic. * @package */ Blockly.Tooltip.unblock = function() { Blockly.Tooltip.blocked_ = false; }; /** * Create the tooltip and show it. * @private */ Blockly.Tooltip.show_ = function() { if (Blockly.Tooltip.blocked_) { // Someone doesn't want us to show tooltips. return; } Blockly.Tooltip.poisonedElement_ = Blockly.Tooltip.element_; if (!Blockly.Tooltip.DIV) { return; } // Erase all existing text. Blockly.Tooltip.DIV.textContent = ''; var tip = Blockly.Tooltip.getTooltipOfObject(Blockly.Tooltip.element_); tip = Blockly.utils.string.wrap(tip, Blockly.Tooltip.LIMIT); // Create new text, line by line. var lines = tip.split('\n'); for (var i = 0; i < lines.length; i++) { var div = document.createElement('div'); div.appendChild(document.createTextNode(lines[i])); Blockly.Tooltip.DIV.appendChild(div); } var rtl = Blockly.Tooltip.element_.RTL; var windowWidth = document.documentElement.clientWidth; var windowHeight = document.documentElement.clientHeight; // Display the tooltip. Blockly.Tooltip.DIV.style.direction = rtl ? 'rtl' : 'ltr'; Blockly.Tooltip.DIV.style.display = 'block'; Blockly.Tooltip.visible = true; // Move the tooltip to just below the cursor. var anchorX = Blockly.Tooltip.lastX_; if (rtl) { anchorX -= Blockly.Tooltip.OFFSET_X + Blockly.Tooltip.DIV.offsetWidth; } else { anchorX += Blockly.Tooltip.OFFSET_X; } var anchorY = Blockly.Tooltip.lastY_ + Blockly.Tooltip.OFFSET_Y; if (anchorY + Blockly.Tooltip.DIV.offsetHeight > windowHeight + window.scrollY) { // Falling off the bottom of the screen; shift the tooltip up. anchorY -= Blockly.Tooltip.DIV.offsetHeight + 2 * Blockly.Tooltip.OFFSET_Y; } if (rtl) { // Prevent falling off left edge in RTL mode. anchorX = Math.max(Blockly.Tooltip.MARGINS - window.scrollX, anchorX); } else { if (anchorX + Blockly.Tooltip.DIV.offsetWidth > windowWidth + window.scrollX - 2 * Blockly.Tooltip.MARGINS) { // Falling off the right edge of the screen; // clamp the tooltip on the edge. anchorX = windowWidth - Blockly.Tooltip.DIV.offsetWidth - 2 * Blockly.Tooltip.MARGINS; } } Blockly.Tooltip.DIV.style.top = anchorY + 'px'; Blockly.Tooltip.DIV.style.left = anchorX + 'px'; };