Files
blockly/core/tooltip.js
Neil Fraser 90b3f75d82 Remove @author tags (#5601)
Our files are up to a decade old, and have churned so much, that the initial author of the file no longer has much meaning.

Furthermore, this will encourage developers to post to the developer group, rather than emailing Googlers (usually me) directly.
2021-10-15 09:50:46 -07:00

469 lines
12 KiB
JavaScript

/**
* @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';
};