Files
blockly/core/tooltip.ts
Maribeth Bottorff 037eb59b89 chore: Lint TsDoc. (#6353)
* chore: add linting for tsdoc

* chore: don't require types on return

* chore: remove redundant fileoverview from ts

* chore: change return to returns and add some newlines

* chore: remove license tag

* chore: don't require params/return docs

* chore: remove spurious struct tags

* Revert "chore: change return to returns and add some newlines"

This reverts commit d6d8656a45.

* chore: don't auto-add param names

* chore: disable require-param bc it breaks on this

* return to returns and add line breaks

* chore: configure additional jsdoc rules

* chore: run format

* Revert "chore: remove license tag"

This reverts commit 173455588a.

* chore: allow license tag format

* chore: only require jsdoc on exported items

* chore: add missing jsdoc or silence where needed

* chore: run format

* chore: lint fixes
2022-08-23 14:27:22 -07:00

490 lines
13 KiB
TypeScript

/**
* @license
* Copyright 2011 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Library to create tooltips for Blockly.
* First, call createDom() after onload.
* Second, set the 'tooltip' property on any SVG element that needs a tooltip.
* If the tooltip is a string, or a function that returns a string, 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
*/
import * as goog from '../closure/goog/goog.js';
goog.declareModuleId('Blockly.Tooltip');
import * as browserEvents from './browser_events.js';
import * as common from './common.js';
import * as blocklyString from './utils/string.js';
/**
* 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.
*
* @alias Blockly.Tooltip.TipInfo
*/
export type TipInfo =
string|{tooltip: AnyDuringMigration}|(() => TipInfo|string|Function);
/**
* A function that renders custom tooltip UI.
* 1st parameter: the div element to render content into.
* 2nd parameter: the element being moused over (i.e., the element for which the
* tooltip should be shown).
*
* @alias Blockly.Tooltip.CustomTooltip
*/
export type CustomTooltip = (p1: Element, p2: Element) => AnyDuringMigration;
/**
* An optional function that renders custom tooltips into the provided DIV. If
* this is defined, the function will be called instead of rendering the default
* tooltip UI.
*/
let customTooltip: CustomTooltip|undefined = undefined;
/**
* Sets a custom function that will be called if present instead of the default
* tooltip UI.
*
* @param customFn A custom tooltip used to render an alternate tooltip UI.
* @alias Blockly.Tooltip.setCustomTooltip
*/
export function setCustomTooltip(customFn: CustomTooltip) {
customTooltip = customFn;
}
/**
* Gets the custom tooltip function.
*
* @returns The custom tooltip function, if defined.
*/
export function getCustomTooltip(): CustomTooltip|undefined {
return customTooltip;
}
/** Is a tooltip currently showing? */
let visible = false;
/**
* Returns whether or not a tooltip is showing
*
* @returns True if a tooltip is showing
* @alias Blockly.Tooltip.isVisible
*/
export function isVisible(): boolean {
return visible;
}
/** Is someone else blocking the tooltip from being shown? */
let blocked = false;
/**
* Maximum width (in characters) of a tooltip.
*
* @alias Blockly.Tooltip.LIMIT
*/
export const LIMIT = 50;
/** PID of suspended thread to clear tooltip on mouse out. */
let mouseOutPid: AnyDuringMigration = 0;
/** PID of suspended thread to show the tooltip. */
let showPid: AnyDuringMigration = 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. */
let element: AnyDuringMigration = 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.
*/
let poisonedElement: AnyDuringMigration = null;
/**
* Horizontal offset between mouse cursor and tooltip.
*
* @alias Blockly.Tooltip.OFFSET_X
*/
export const OFFSET_X = 0;
/**
* Vertical offset between mouse cursor and tooltip.
*
* @alias Blockly.Tooltip.OFFSET_Y
*/
export const OFFSET_Y = 10;
/**
* Radius mouse can move before killing tooltip.
*
* @alias Blockly.Tooltip.RADIUS_OK
*/
export const RADIUS_OK = 10;
/**
* Delay before tooltip appears.
*
* @alias Blockly.Tooltip.HOVER_MS
*/
export const HOVER_MS = 750;
/**
* Horizontal padding between tooltip and screen edge.
*
* @alias Blockly.Tooltip.MARGINS
*/
export const MARGINS = 5;
/** The HTML container. Set once by createDom. */
let containerDiv: HTMLDivElement|null = null;
/**
* Returns the HTML tooltip container.
*
* @returns The HTML tooltip container.
* @alias Blockly.Tooltip.getDiv
*/
export function getDiv(): HTMLDivElement|null {
return containerDiv;
}
/**
* Returns the tooltip text for the given element.
*
* @param object The object to get the tooltip text of.
* @returns The tooltip text of the element.
* @alias Blockly.Tooltip.getTooltipOfObject
*/
export function getTooltipOfObject(object: AnyDuringMigration|null): string {
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 '';
}
/**
* Returns the target object that the given object is targeting for its
* tooltip. Could be the object itself.
*
* @param obj The object are trying to find the target tooltip object of.
* @returns The target tooltip object.
*/
function getTargetObject(obj: object|null): {tooltip: AnyDuringMigration}|null {
while (obj && (obj as any).tooltip) {
if (typeof (obj as any).tooltip === 'string' ||
typeof (obj as any).tooltip === 'function') {
return obj as {tooltip: string | (() => string)};
}
obj = (obj as any).tooltip;
}
return null;
}
/**
* Create the tooltip div and inject it onto the page.
*
* @alias Blockly.Tooltip.createDom
*/
export function createDom() {
if (containerDiv) {
return; // Already created.
}
// Create an HTML container for popup overlays (e.g. editor widgets).
containerDiv = document.createElement('div');
containerDiv.className = 'blocklyTooltipDiv';
const container = common.getParentContainer() || document.body;
container.appendChild(containerDiv);
}
/**
* Binds the required mouse events onto an SVG element.
*
* @param element SVG element onto which tooltip is to be bound.
* @alias Blockly.Tooltip.bindMouseEvents
*/
export function bindMouseEvents(element: Element) {
// TODO (#6097): Don't stash wrapper info on the DOM.
(element as AnyDuringMigration).mouseOverWrapper_ =
browserEvents.bind(element, 'mouseover', null, onMouseOver);
(element as AnyDuringMigration).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);
}
/**
* Unbinds tooltip mouse events from the SVG element.
*
* @param element SVG element onto which tooltip is bound.
* @alias Blockly.Tooltip.unbindMouseEvents
*/
export function unbindMouseEvents(element: Element) {
if (!element) {
return;
}
// TODO (#6097): Don't stash wrapper info on the DOM.
browserEvents.unbind((element as AnyDuringMigration).mouseOverWrapper_);
browserEvents.unbind((element as AnyDuringMigration).mouseOutWrapper_);
element.removeEventListener('mousemove', onMouseMove);
}
/**
* Hide the tooltip if the mouse is over a different object.
* Initialize the tooltip to potentially appear for this object.
*
* @param e Mouse event.
*/
function onMouseOver(e: Event) {
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 = 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 _e Mouse event.
*/
function onMouseOut(_e: Event) {
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 e Mouse event.
*/
function onMouseMove(e: Event) {
if (!element || !(element as AnyDuringMigration).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.
// AnyDuringMigration because: Property 'pageX' does not exist on type
// 'Event'.
const dx = lastX - (e as AnyDuringMigration).pageX;
// AnyDuringMigration because: Property 'pageY' does not exist on type
// 'Event'.
const dy = lastY - (e as AnyDuringMigration).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.
// AnyDuringMigration because: Property 'pageX' does not exist on type
// 'Event'.
lastX = (e as AnyDuringMigration).pageX;
// AnyDuringMigration because: Property 'pageY' does not exist on type
// 'Event'.
lastY = (e as AnyDuringMigration).pageY;
showPid = setTimeout(show, HOVER_MS);
}
}
/**
* Dispose of the tooltip.
*
* @alias Blockly.Tooltip.dispose
* @internal
*/
export function dispose() {
element = null;
poisonedElement = null;
hide();
}
/**
* Hide the tooltip.
*
* @alias Blockly.Tooltip.hide
*/
export function hide() {
if (visible) {
visible = false;
if (containerDiv) {
containerDiv.style.display = 'none';
}
}
if (showPid) {
clearTimeout(showPid);
}
}
/**
* Hide any in-progress tooltips and block showing new tooltips until the next
* call to unblock().
*
* @alias Blockly.Tooltip.block
* @internal
*/
export function block() {
hide();
blocked = true;
}
/**
* Unblock tooltips: allow them to be scheduled and shown according to their own
* logic.
*
* @alias Blockly.Tooltip.unblock
* @internal
*/
export function unblock() {
blocked = false;
}
/** Renders the tooltip content into the tooltip div. */
function renderContent() {
if (!containerDiv || !element) {
// This shouldn't happen, but if it does, we can't render.
return;
}
if (typeof customTooltip === 'function') {
customTooltip(containerDiv, element);
} else {
renderDefaultContent();
}
}
/** Renders the default tooltip UI. */
function renderDefaultContent() {
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]));
containerDiv!.appendChild(div);
}
}
/**
* Gets the coordinates for the tooltip div, taking into account the edges of
* the screen to prevent showing the tooltip offscreen.
*
* @param rtl True if the tooltip should be in right-to-left layout.
* @returns Coordinates at which the tooltip div should be placed.
*/
function getPosition(rtl: boolean): {x: number, y: number} {
// Position the tooltip just below the cursor.
const windowWidth = document.documentElement.clientWidth;
const windowHeight = document.documentElement.clientHeight;
let anchorX = lastX;
if (rtl) {
anchorX -= OFFSET_X + containerDiv!.offsetWidth;
} else {
anchorX += OFFSET_X;
}
let anchorY = lastY + OFFSET_Y;
if (anchorY + containerDiv!.offsetHeight > windowHeight + window.scrollY) {
// Falling off the bottom of the screen; shift the tooltip up.
anchorY -= containerDiv!.offsetHeight + 2 * OFFSET_Y;
}
if (rtl) {
// Prevent falling off left edge in RTL mode.
anchorX = Math.max(MARGINS - window.scrollX, anchorX);
} else {
if (anchorX + containerDiv!.offsetWidth >
windowWidth + window.scrollX - 2 * MARGINS) {
// Falling off the right edge of the screen;
// clamp the tooltip on the edge.
anchorX = windowWidth - containerDiv!.offsetWidth - 2 * MARGINS;
}
}
return {x: anchorX, y: anchorY};
}
/** Create the tooltip and show it. */
function show() {
if (blocked) {
// Someone doesn't want us to show tooltips.
return;
}
poisonedElement = element;
if (!containerDiv) {
return;
}
// Erase all existing text.
containerDiv.textContent = '';
// Add new content.
renderContent();
// Display the tooltip.
const rtl = (element as any).RTL;
containerDiv.style.direction = rtl ? 'rtl' : 'ltr';
containerDiv.style.display = 'block';
visible = true;
const {x, y} = getPosition(rtl);
containerDiv.style.left = x + 'px';
containerDiv.style.top = y + 'px';
}