From 6841ccc99fdbcc5f6d5a63bb36cb3b6ebd2be246 Mon Sep 17 00:00:00 2001 From: Maribeth Bottorff Date: Thu, 3 Mar 2022 17:23:02 -0800 Subject: [PATCH] feat: Allow developers to set a custom tooltip rendering function. (#5956) * refactor: refactor tooltip show method * refactor: make Tooltip a class that is accessed via a singleton * revert: "refactor: make Tooltip a class that is accessed via a singleton" This reverts commit b3d543cc3560fbe44b16ac8085e8d6cb141f8dbe. * feat: add the ability to set a custom tooltip function * fix: check for null where it matters for types * feat: Add a test for the custom tooltip function * fix: fix formatting * fix: format test * fix: remove unnecessary teardown call in test --- core/tooltip.js | 133 ++++++++++++++++++++++++++++-------- tests/mocha/tooltip_test.js | 71 ++++++++++++++----- 2 files changed, 156 insertions(+), 48 deletions(-) diff --git a/core/tooltip.js b/core/tooltip.js index e06e9b9d216..e3e32b92e8a 100644 --- a/core/tooltip.js +++ b/core/tooltip.js @@ -4,23 +4,15 @@ * 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. + * First, call createDom() 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. + * 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 */ goog.module('Blockly.Tooltip'); @@ -42,6 +34,46 @@ const deprecation = goog.require('Blockly.utils.deprecation'); let TipInfo; exports.TipInfo = TipInfo; +/** + * 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). + * @typedef {function(!Element, !Element)} + * @alias Blockly.Tooltip.CustomTooltip + */ +let CustomTooltip; +exports.CustomTooltip = CustomTooltip; + +/** + * 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. + * @type {!CustomTooltip|undefined} + */ +let customTooltip = undefined; + +/** + * Sets a custom function that will be called if present instead of the default + * tooltip UI. + * @param {!CustomTooltip} customFn A custom tooltip used to render an alternate + * tooltip UI. + * @alias Blockly.Tooltip.setCustomTooltip + */ +const setCustomTooltip = function(customFn) { + customTooltip = customFn; +}; +exports.setCustomTooltip = setCustomTooltip; + +/** + * Gets the custom tooltip function. + * @returns {!CustomTooltip|undefined} The custom tooltip function, if defined. + */ +const getCustomTooltip = function() { + return customTooltip; +}; +exports.getCustomTooltip = getCustomTooltip; + /** * Is a tooltip currently showing? * @type {boolean} @@ -410,19 +442,24 @@ const unblock = function() { exports.unblock = unblock; /** - * Create the tooltip and show it. + * Renders the tooltip content into the tooltip div. */ -const show = function() { - if (blocked) { - // Someone doesn't want us to show tooltips. +const renderContent = function() { + if (!DIV || !element) { + // This shouldn't happen, but if it does, we can't render. return; } - poisonedElement = element; - if (!DIV) { - return; + if (typeof customTooltip === 'function') { + customTooltip(DIV, element); + } else { + renderDefaultContent(); } - // Erase all existing text. - DIV.textContent = ''; +}; + +/** + * Renders the default tooltip UI. + */ +const renderDefaultContent = function() { let tip = getTooltipOfObject(element); tip = blocklyString.wrap(tip, LIMIT); // Create new text, line by line. @@ -432,26 +469,33 @@ const show = function() { div.appendChild(document.createTextNode(lines[i])); DIV.appendChild(div); } - const rtl = /** @type {{RTL: boolean}} */ (element).RTL; +}; + +/** + * Gets the coordinates for the tooltip div, taking into account the edges of + * the screen to prevent showing the tooltip offscreen. + * @param {boolean} rtl True if the tooltip should be in right-to-left layout. + * @returns {{x: number, y: number}} Coordinates at which the tooltip div should + * be placed. + */ +const getPosition = function(rtl) { + // Position the tooltip just below the cursor. 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; + 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); @@ -463,6 +507,35 @@ const show = function() { anchorX = windowWidth - DIV.offsetWidth - 2 * MARGINS; } } - DIV.style.top = anchorY + 'px'; - DIV.style.left = anchorX + 'px'; + + return {x: anchorX, y: anchorY}; +}; + +/** + * 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 = ''; + + // Add new content. + renderContent(); + + // Display the tooltip. + const rtl = /** @type {{RTL: boolean}} */ (element).RTL; + DIV.style.direction = rtl ? 'rtl' : 'ltr'; + DIV.style.display = 'block'; + visible = true; + + const {x, y} = getPosition(rtl); + DIV.style.left = x + 'px'; + DIV.style.top = y + 'px'; }; diff --git a/tests/mocha/tooltip_test.js b/tests/mocha/tooltip_test.js index a2c5bb216f1..7c9cc8bf36a 100644 --- a/tests/mocha/tooltip_test.js +++ b/tests/mocha/tooltip_test.js @@ -13,32 +13,63 @@ suite('Tooltip', function() { setup(function() { sharedTestSetup.call(this); this.workspace = new Blockly.Workspace(); + + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'test_block', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'FIELD', + }, + ], + }, + ]); }); teardown(function() { + delete Blockly.Blocks['test_block']; sharedTestTeardown.call(this); }); - suite('set/getTooltip', function() { + suite('Custom Tooltip', function() { setup(function() { - Blockly.defineBlocksWithJsonArray([ - { - "type": "test_block", - "message0": "%1", - "args0": [ - { - "type": "field_input", - "name": "FIELD", - }, - ], - }, - ]); + this.renderedWorkspace = Blockly.inject('blocklyDiv', {}); }); teardown(function() { - delete Blockly.Blocks["test_block"]; + workspaceTeardown.call(this, this.renderedWorkspace); + }); + + test('Custom function is called', function() { + // Custom tooltip function is registered and should be called when mouse + // events are fired. + let wasCalled = false; + const customFn = function() { + wasCalled = true; + }; + Blockly.Tooltip.setCustomTooltip(customFn); + + this.block = this.renderedWorkspace.newBlock('test_block'); + this.block.setTooltip('Test Tooltip'); + + // Fire pointer events directly on the relevant SVG. + // Note the 'pointerover', due to the events registered through + // Blockly.browserEvents.bind being registered as pointer events rather + // than mouse events. Mousemove event is registered directly on the + // element rather than through browserEvents. + this.block.pathObject.svgPath.dispatchEvent( + new MouseEvent('pointerover')); + this.block.pathObject.svgPath.dispatchEvent(new MouseEvent('mousemove')); + this.clock.runAll(); + + chai.assert.isTrue( + wasCalled, 'Expected custom tooltip function to have been called'); }); + }); + suite('set/getTooltip', function() { const tooltipText = 'testTooltip'; function assertTooltip(obj) { @@ -97,7 +128,8 @@ suite('Tooltip', function() { test('Function returning object', function() { setFunctionReturningObjectTooltip(this.block); - chai.assert.throws(this.block.getTooltip.bind(this.block), + chai.assert.throws( + this.block.getTooltip.bind(this.block), 'Tooltip function must return a string.'); }); @@ -136,7 +168,8 @@ suite('Tooltip', function() { test('Function returning object', function() { setFunctionReturningObjectTooltip(this.block); - chai.assert.throws(this.block.getTooltip.bind(this.block), + chai.assert.throws( + this.block.getTooltip.bind(this.block), 'Tooltip function must return a string.'); }); @@ -169,7 +202,8 @@ suite('Tooltip', function() { test('Function returning object', function() { setFunctionReturningObjectTooltip(this.field); - chai.assert.throws(this.field.getTooltip.bind(this.field), + chai.assert.throws( + this.field.getTooltip.bind(this.field), 'Tooltip function must return a string.'); }); @@ -215,7 +249,8 @@ suite('Tooltip', function() { test('Function returning object', function() { setFunctionReturningObjectTooltip(this.field); - chai.assert.throws(this.field.getTooltip.bind(this.field), + chai.assert.throws( + this.field.getTooltip.bind(this.field), 'Tooltip function must return a string.'); });