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); + }); + }); + }); +});