/** * @license * Copyright 2011 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Library to create tooltips for Blockly. * First, call 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 bindMouseEvents(e) passing the SVG element. */ 'use strict'; /** * Library to create tooltips for Blockly. * First, call 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 bindMouseEvents(e) passing the SVG element. * @namespace Blockly.Tooltip */ goog.module('Blockly.Tooltip'); const blocklyString = goog.require('Blockly.utils.string'); const browserEvents = goog.require('Blockly.browserEvents'); const common = goog.require('Blockly.common'); const deprecation = goog.require('Blockly.utils.deprecation'); /** * 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)} * @alias Blockly.Tooltip.TipInfo */ let TipInfo; exports.TipInfo = TipInfo; /** * Is a tooltip currently showing? * @type {boolean} */ let visible = false; /** * Returns whether or not a tooltip is showing * @returns {boolean} True if a tooltip is showing * @alias Blockly.Tooltip.isVisible */ const isVisible = function() { return visible; }; exports.isVisible = isVisible; Object.defineProperties(exports, { /** * Is a tooltip currently showing? * @name Blockly.Tooltip.visible * @type {boolean} * @deprecated Use Blockly.Tooltip.isVisible() instead. (September * 2021) * @suppress {checkTypes} */ visible: { get: function() { deprecation.warn( 'Blockly.Tooltip.visible', 'September 2021', 'September 2022', 'Blockly.Tooltip.isVisible()'); return isVisible(); }, }, }); /** * Is someone else blocking the tooltip from being shown? * @type {boolean} */ let blocked = false; /** * Maximum width (in characters) of a tooltip. * @alias Blockly.Tooltip.LIMIT */ const LIMIT = 50; exports.LIMIT = LIMIT; /** * PID of suspended thread to clear tooltip on mouse out. */ let mouseOutPid = 0; /** * PID of suspended thread to show the tooltip. */ let showPid = 0; /** * Last observed X location of the mouse pointer (freezes when tooltip appears). */ let lastX = 0; /** * Last observed Y location of the mouse pointer (freezes when tooltip appears). */ let lastY = 0; /** * Current element being pointed at. * @type {Element} */ let 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} */ let poisonedElement = null; /** * Horizontal offset between mouse cursor and tooltip. * @alias Blockly.Tooltip.OFFSET_X */ const OFFSET_X = 0; exports.OFFSET_X = OFFSET_X; /** * Vertical offset between mouse cursor and tooltip. * @alias Blockly.Tooltip.OFFSET_Y */ const OFFSET_Y = 10; exports.OFFSET_Y = OFFSET_Y; /** * Radius mouse can move before killing tooltip. * @alias Blockly.Tooltip.RADIUS_OK */ const RADIUS_OK = 10; exports.RADIUS_OK = RADIUS_OK; /** * Delay before tooltip appears. * @alias Blockly.Tooltip.HOVER_MS */ const HOVER_MS = 750; exports.HOVER_MS = HOVER_MS; /** * Horizontal padding between tooltip and screen edge. * @alias Blockly.Tooltip.MARGINS */ const MARGINS = 5; exports.MARGINS = MARGINS; /** * The HTML container. Set once by createDom. * @type {Element} */ let DIV = null; /** * Returns the HTML tooltip container. * @returns {Element} The HTML tooltip container. * @alias Blockly.Tooltip.getDiv */ const getDiv = function() { return DIV; }; exports.getDiv = getDiv; Object.defineProperties(exports, { /** * The HTML container. Set once by createDom. * @name Blockly.Tooltip.DIV * @type {Element} * @deprecated Use Blockly.Tooltip.getDiv() and .setDiv(). * (September 2021) * @suppress {checkTypes} */ DIV: { get: function() { deprecation.warn( 'Blockly.Tooltip.DIV', 'September 2021', 'September 2022', 'Blockly.Tooltip.getDiv()'); return getDiv(); }, }, }); /** * Returns the tooltip text for the given element. * @param {?Object} object The object to get the tooltip text of. * @return {string} The tooltip text of the element. * @alias Blockly.Tooltip.getTooltipOfObject */ const getTooltipOfObject = function(object) { const obj = getTargetObject(object); if (obj) { let tooltip = obj.tooltip; while (typeof tooltip === 'function') { tooltip = tooltip(); } if (typeof tooltip !== 'string') { throw Error('Tooltip function must return a string.'); } return tooltip; } return ''; }; exports.getTooltipOfObject = getTooltipOfObject; /** * 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. * @return {?{tooltip}} The target tooltip object. */ const 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. * @alias Blockly.Tooltip.createDom */ const createDom = function() { if (DIV) { return; // Already created. } // Create an HTML container for popup overlays (e.g. editor widgets). DIV = document.createElement('div'); DIV.className = 'blocklyTooltipDiv'; const container = common.getParentContainer() || document.body; container.appendChild(DIV); }; exports.createDom = createDom; /** * Binds the required mouse events onto an SVG element. * @param {!Element} element SVG element onto which tooltip is to be bound. * @alias Blockly.Tooltip.bindMouseEvents */ const bindMouseEvents = function(element) { element.mouseOverWrapper_ = browserEvents.bind(element, 'mouseover', null, onMouseOver); element.mouseOutWrapper_ = browserEvents.bind(element, 'mouseout', null, 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', onMouseMove, false); }; exports.bindMouseEvents = bindMouseEvents; /** * Unbinds tooltip mouse events from the SVG element. * @param {!Element} element SVG element onto which tooltip is bound. * @alias Blockly.Tooltip.unbindMouseEvents */ const unbindMouseEvents = function(element) { if (!element) { return; } browserEvents.unbind(element.mouseOverWrapper_); browserEvents.unbind(element.mouseOutWrapper_); element.removeEventListener('mousemove', onMouseMove); }; exports.unbindMouseEvents = unbindMouseEvents; /** * 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. */ const onMouseOver = function(e) { if (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. const newElement = /** @type {Element} */ (getTargetObject(e.currentTarget)); if (element !== newElement) { hide(); poisonedElement = null; element = newElement; } // Forget about any immediately preceding mouseOut event. clearTimeout(mouseOutPid); }; /** * Hide the tooltip if the mouse leaves the object and enters the workspace. * @param {!Event} _e Mouse event. */ const onMouseOut = function(_e) { if (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. mouseOutPid = setTimeout(function() { element = null; poisonedElement = null; hide(); }, 1); clearTimeout(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. */ const onMouseMove = function(e) { if (!element || !element.tooltip) { // No tooltip here to show. return; } else if (blocked) { // Someone doesn't want us to show tooltips. We are probably handling a // user gesture, such as a click or drag. return; } if (visible) { // Compute the distance between the mouse position when the tooltip was // shown and the current mouse position. Pythagorean theorem. const dx = lastX - e.pageX; const dy = lastY - e.pageY; if (Math.sqrt(dx * dx + dy * dy) > RADIUS_OK) { hide(); } } else if (poisonedElement !== element) { // The mouse moved, clear any previously scheduled tooltip. clearTimeout(showPid); // Maybe this time the mouse will stay put. Schedule showing of tooltip. lastX = e.pageX; lastY = e.pageY; showPid = setTimeout(show, HOVER_MS); } }; /** * Dispose of the tooltip. * @alias Blockly.Tooltip.dispose * @package */ const dispose = function() { element = null; poisonedElement = null; hide(); }; exports.dispose = dispose; /** * Hide the tooltip. * @alias Blockly.Tooltip.hide */ const hide = function() { if (visible) { visible = false; if (DIV) { DIV.style.display = 'none'; } } if (showPid) { clearTimeout(showPid); } }; exports.hide = hide; /** * Hide any in-progress tooltips and block showing new tooltips until the next * call to unblock(). * @alias Blockly.Tooltip.block * @package */ const block = function() { hide(); blocked = true; }; exports.block = block; /** * Unblock tooltips: allow them to be scheduled and shown according to their own * logic. * @alias Blockly.Tooltip.unblock * @package */ const unblock = function() { blocked = false; }; exports.unblock = unblock; /** * Create the tooltip and show it. */ const show = function() { if (blocked) { // Someone doesn't want us to show tooltips. return; } poisonedElement = element; if (!DIV) { return; } // Erase all existing text. DIV.textContent = ''; let tip = getTooltipOfObject(element); tip = blocklyString.wrap(tip, LIMIT); // Create new text, line by line. const lines = tip.split('\n'); for (let i = 0; i < lines.length; i++) { const div = document.createElement('div'); div.appendChild(document.createTextNode(lines[i])); DIV.appendChild(div); } const rtl = /** @type {{RTL: boolean}} */ (element).RTL; const windowWidth = document.documentElement.clientWidth; const windowHeight = document.documentElement.clientHeight; // Display the tooltip. DIV.style.direction = rtl ? 'rtl' : 'ltr'; DIV.style.display = 'block'; visible = true; // Move the tooltip to just below the cursor. let anchorX = lastX; if (rtl) { anchorX -= OFFSET_X + DIV.offsetWidth; } else { anchorX += OFFSET_X; } let anchorY = lastY + OFFSET_Y; if (anchorY + DIV.offsetHeight > windowHeight + window.scrollY) { // Falling off the bottom of the screen; shift the tooltip up. anchorY -= DIV.offsetHeight + 2 * OFFSET_Y; } if (rtl) { // Prevent falling off left edge in RTL mode. anchorX = Math.max(MARGINS - window.scrollX, anchorX); } else { if (anchorX + DIV.offsetWidth > windowWidth + window.scrollX - 2 * MARGINS) { // Falling off the right edge of the screen; // clamp the tooltip on the edge. anchorX = windowWidth - DIV.offsetWidth - 2 * MARGINS; } } DIV.style.top = anchorY + 'px'; DIV.style.left = anchorX + 'px'; };