diff --git a/core/block.js b/core/block.js
index a9a943dc5..c33d68433 100644
--- a/core/block.js
+++ b/core/block.js
@@ -24,6 +24,7 @@ goog.require('Blockly.Extensions');
goog.require('Blockly.fieldRegistry');
goog.require('Blockly.Input');
goog.require('Blockly.navigation');
+goog.require('Blockly.Tooltip');
goog.require('Blockly.utils');
goog.require('Blockly.utils.deprecation');
goog.require('Blockly.utils.Coordinate');
@@ -73,7 +74,7 @@ Blockly.Block = function(workspace, prototypeName, opt_id) {
* @private
*/
this.disabled = false;
- /** @type {string|!Function} */
+ /** @type {!Blockly.Tooltip.TipInfo} */
this.tooltip = '';
/** @type {boolean} */
this.contextMenu = true;
@@ -905,14 +906,23 @@ Blockly.Block.prototype.setHelpUrl = function(url) {
};
/**
- * Change the tooltip text for a block.
- * @param {string|!Function} newTip Text for tooltip or a parent element to
- * link to for its tooltip. May be a function that returns a string.
+ * Sets the tooltip for this block.
+ * @param {!Blockly.Tooltip.TipInfo} newTip The text for the tooltip, a function
+ * that returns the text for the tooltip, or a parent object whose tooltip
+ * will be used. To not display a tooltip pass the empty string.
*/
Blockly.Block.prototype.setTooltip = function(newTip) {
this.tooltip = newTip;
};
+/**
+ * Returns the tooltip text for this block.
+ * @returns {!string} The tooltip text for this block.
+ */
+Blockly.Block.prototype.getTooltip = function() {
+ return Blockly.Tooltip.getTooltipOfObject(this);
+};
+
/**
* Get the colour of a block.
* @return {string} #RRGGBB string.
diff --git a/core/field.js b/core/field.js
index 21f28eb76..cbe668120 100644
--- a/core/field.js
+++ b/core/field.js
@@ -17,6 +17,7 @@ goog.provide('Blockly.Field');
goog.require('Blockly.Events');
goog.require('Blockly.Events.BlockChange');
goog.require('Blockly.Gesture');
+goog.require('Blockly.Tooltip');
goog.require('Blockly.utils');
goog.require('Blockly.utils.deprecation');
goog.require('Blockly.utils.dom');
@@ -66,7 +67,7 @@ Blockly.Field = function(value, opt_validator, opt_config) {
/**
* Used to cache the field's tooltip value if setTooltip is called when the
* field is not yet initialized. Is *not* guaranteed to be accurate.
- * @type {string|Function|!SVGElement}
+ * @type {?Blockly.Tooltip.TipInfo}
* @private
*/
this.tooltip_ = null;
@@ -996,23 +997,36 @@ Blockly.Field.prototype.onMouseDown_ = function(e) {
};
/**
- * Change the tooltip text for this field.
- * @param {string|Function|!SVGElement} newTip Text for tooltip or a parent
- * element to link to for its tooltip.
+ * Sets the tooltip for this field.
+ * @param {?Blockly.Tooltip.TipInfo} newTip The
+ * text for the tooltip, a function that returns the text for the tooltip, a
+ * parent object whose tooltip will be used, or null to display the tooltip
+ * of the parent block. To not display a tooltip pass the empty string.
*/
Blockly.Field.prototype.setTooltip = function(newTip) {
+ if (!newTip && newTip !== '') { // If null or undefined.
+ newTip = this.sourceBlock_;
+ }
var clickTarget = this.getClickTarget_();
- if (!clickTarget) {
+ if (clickTarget) {
+ clickTarget.tooltip = newTip;
+ } else {
// Field has not been initialized yet.
this.tooltip_ = newTip;
- return;
}
+};
- if (!newTip && newTip !== '') { // If null or undefined.
- clickTarget.tooltip = this.sourceBlock_;
- } else {
- clickTarget.tooltip = newTip;
+/**
+ * Returns the tooltip text for this field.
+ * @returns {string} The tooltip text for this field.
+ */
+Blockly.Field.prototype.getTooltip = function() {
+ var clickTarget = this.getClickTarget_();
+ if (clickTarget) {
+ return Blockly.Tooltip.getTooltipOfObject(clickTarget);
}
+ // Field has not been initialized yet. Return stashed this.tooltip_ value.
+ return Blockly.Tooltip.getTooltipOfObject({tooltip: this.tooltip_});
};
/**
diff --git a/core/tooltip.js b/core/tooltip.js
index 966807dc7..70965c7aa 100644
--- a/core/tooltip.js
+++ b/core/tooltip.js
@@ -23,6 +23,14 @@ goog.provide('Blockly.Tooltip');
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?
@@ -111,6 +119,45 @@ Blockly.Tooltip.MARGINS = 5;
*/
Blockly.Tooltip.DIV = null;
+/**
+ * Returns the tooltip text for the given element.
+ * @param {?Object} object The object to get the 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.
*/
@@ -167,11 +214,8 @@ Blockly.Tooltip.onMouseOver_ = function(e) {
}
// 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 = e.currentTarget;
- while ((typeof element.tooltip != 'string') &&
- (typeof element.tooltip != 'function')) {
- element = element.tooltip;
- }
+ var element = /** @type {Element} */ (Blockly.Tooltip.getTargetObject_(
+ e.currentTarget));
if (Blockly.Tooltip.element_ != element) {
Blockly.Tooltip.hide();
Blockly.Tooltip.poisonedElement_ = null;
@@ -296,11 +340,7 @@ Blockly.Tooltip.show_ = function() {
}
// Erase all existing text.
Blockly.Tooltip.DIV.textContent = '';
- // Get the new text.
- var tip = Blockly.Tooltip.element_.tooltip;
- while (typeof tip == 'function') {
- tip = tip();
- }
+ 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');
diff --git a/tests/mocha/index.html b/tests/mocha/index.html
index 7af275f12..08a7fb642 100644
--- a/tests/mocha/index.html
+++ b/tests/mocha/index.html
@@ -83,6 +83,7 @@
+
diff --git a/tests/mocha/tooltip_test.js b/tests/mocha/tooltip_test.js
new file mode 100644
index 000000000..f378bd506
--- /dev/null
+++ b/tests/mocha/tooltip_test.js
@@ -0,0 +1,230 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+suite('Tooltip', function() {
+
+ setup(function() {
+ sharedTestSetup.call(this);
+ this.workspace = new Blockly.Workspace();
+ });
+
+ teardown(function() {
+ sharedTestTeardown.call(this);
+ });
+
+ suite('set/getTooltip', function() {
+ setup(function() {
+ Blockly.defineBlocksWithJsonArray([
+ {
+ "type": "test_block",
+ "message0": "%1",
+ "args0": [
+ {
+ "type": "field_input",
+ "name": "FIELD"
+ }
+ ]
+ }
+ ]);
+ });
+
+ teardown(function() {
+ delete Blockly.Blocks["test_block"];
+ });
+
+ var tooltipText = 'testTooltip';
+
+ function assertTooltip(obj) {
+ chai.assert.equal(obj.getTooltip(), tooltipText);
+ }
+
+ function setStringTooltip(obj) {
+ obj.setTooltip(tooltipText);
+ }
+
+ function setFunctionTooltip(obj) {
+ obj.setTooltip(() => tooltipText);
+ }
+
+ function setNestedFunctionTooltip(obj) {
+ function nestFunction(fn, count) {
+ if (!count) {
+ return fn;
+ }
+ return () => nestFunction(fn, --count);
+ }
+ obj.setTooltip(nestFunction(() => tooltipText, 5));
+ }
+
+ function setFunctionReturningObjectTooltip(obj) {
+ obj.setTooltip(() => {
+ return {
+ tooltip: tooltipText
+ };
+ });
+ }
+
+ function setObjectTooltip(obj) {
+ obj.setTooltip({tooltip: tooltipText});
+ }
+
+ suite('Headless Blocks', function() {
+ setup(function() {
+ this.block = this.workspace.newBlock('test_block');
+ });
+
+ test('String', function() {
+ setStringTooltip(this.block);
+ assertTooltip(this.block);
+ });
+
+ test('Function', function() {
+ setFunctionTooltip(this.block);
+ assertTooltip(this.block);
+ });
+
+ test('Nested Function', function() {
+ setNestedFunctionTooltip(this.block);
+ assertTooltip(this.block);
+ });
+
+ test('Function returning object', function() {
+ setFunctionReturningObjectTooltip(this.block);
+ chai.assert.throws(this.block.getTooltip.bind(this.block),
+ 'Tooltip function must return a string.');
+ });
+
+ test('Object', function() {
+ setObjectTooltip(this.block);
+ assertTooltip(this.block);
+ });
+ });
+
+ suite('Rendered Blocks', function() {
+ setup(function() {
+ this.renderedWorkspace = Blockly.inject('blocklyDiv');
+ this.block = this.renderedWorkspace.newBlock('test_block');
+ this.block.initSvg();
+ this.block.render();
+ });
+
+ teardown(function() {
+ workspaceTeardown.call(this, this.renderedWorkspace);
+ });
+
+ test('String', function() {
+ setStringTooltip(this.block);
+ assertTooltip(this.block);
+ });
+
+ test('Function', function() {
+ setFunctionTooltip(this.block);
+ assertTooltip(this.block);
+ });
+
+ test('Nested Function', function() {
+ setNestedFunctionTooltip(this.block);
+ assertTooltip(this.block);
+ });
+
+ test('Function returning object', function() {
+ setFunctionReturningObjectTooltip(this.block);
+ chai.assert.throws(this.block.getTooltip.bind(this.block),
+ 'Tooltip function must return a string.');
+ });
+
+ test('Object', function() {
+ setObjectTooltip(this.block);
+ assertTooltip(this.block);
+ });
+ });
+
+ suite('Headless Fields', function() {
+ setup(function() {
+ this.block = this.workspace.newBlock('test_block');
+ this.field = this.block.getField('FIELD');
+ });
+
+ test('String', function() {
+ setStringTooltip(this.field);
+ assertTooltip(this.field);
+ });
+
+ test('Function', function() {
+ setFunctionTooltip(this.field);
+ assertTooltip(this.field);
+ });
+
+ test('Nested Function', function() {
+ setNestedFunctionTooltip(this.field);
+ assertTooltip(this.field);
+ });
+
+ test('Function returning object', function() {
+ setFunctionReturningObjectTooltip(this.field);
+ chai.assert.throws(this.field.getTooltip.bind(this.field),
+ 'Tooltip function must return a string.');
+ });
+
+ test('Object', function() {
+ setObjectTooltip(this.field);
+ assertTooltip(this.field);
+ });
+
+ test('Null', function() {
+ setStringTooltip(this.block);
+ this.field.setTooltip(null);
+ assertTooltip(this.field);
+ });
+ });
+
+ suite('Rendered Fields', function() {
+ setup(function() {
+ this.renderedWorkspace = Blockly.inject('blocklyDiv');
+ this.block = this.renderedWorkspace.newBlock('test_block');
+ this.block.initSvg();
+ this.block.render();
+ this.field = this.block.getField('FIELD');
+ });
+
+ teardown(function() {
+ workspaceTeardown.call(this, this.renderedWorkspace);
+ });
+
+ test('String', function() {
+ setStringTooltip(this.field);
+ assertTooltip(this.field);
+ });
+
+ test('Function', function() {
+ setFunctionTooltip(this.field);
+ assertTooltip(this.field);
+ });
+
+ test('Nested Function', function() {
+ setNestedFunctionTooltip(this.field);
+ assertTooltip(this.field);
+ });
+
+ test('Function returning object', function() {
+ setFunctionReturningObjectTooltip(this.field);
+ chai.assert.throws(this.field.getTooltip.bind(this.field),
+ 'Tooltip function must return a string.');
+ });
+
+ test('Object', function() {
+ setObjectTooltip(this.field);
+ assertTooltip(this.field);
+ });
+
+ test('Null', function() {
+ setStringTooltip(this.block);
+ this.field.setTooltip(null);
+ assertTooltip(this.field);
+ });
+ });
+ });
+});