From 409983b282d8f3b1fa9396afe13e2fdeb7dc3a12 Mon Sep 17 00:00:00 2001 From: Preet Vadaliya Date: Fri, 11 Sep 2026 15:01:27 +0200 Subject: [PATCH] Tap to open flydown, double-tap to edit on touch devices (fixes #40) --- block-lexical-variables/FIELDS.md | 4 +- block-lexical-variables/README.md | 3 +- block-lexical-variables/package-lock.json | 179 +++++++++--- block-lexical-variables/package.json | 5 +- .../src/fields/field_flydown.js | 114 +++++++- .../test/field_flydown_touch.mocha.js | 265 ++++++++++++++++++ 6 files changed, 519 insertions(+), 51 deletions(-) create mode 100644 block-lexical-variables/test/field_flydown_touch.mocha.js diff --git a/block-lexical-variables/FIELDS.md b/block-lexical-variables/FIELDS.md index 2ccc480..c16320a 100644 --- a/block-lexical-variables/FIELDS.md +++ b/block-lexical-variables/FIELDS.md @@ -3,8 +3,8 @@ There are four main fields defined by the Lexical Variable plugin: * `FieldLexicalVariable` \- A dropdown field which shows the names of any variables that are in scope (i.e. that are referenceable) at the point of the program that that field's corresponding block appears. Typically this is used for variable getter and setter blocks -* `FieldGlobalFlydown` \- A field which is used to name (and rename) a global variable. It also provides a "flydown" which is displayed when the field is hovered over. The flydown enables the user to select getter or setter blocks corresponding to the global variable. This field is typically used as part of a block which defines a global variable. -* `FieldParameterFlydown` \- A somewhat poorly named field which is similar to the `FieldGlobalFlydown`, but is used for lexically scoped variables. It is typically used to define such variables in blocks that introduce lexical scopes, e.g. for the parameters of procedure/function (hence the "parameter" in the field name) definitions, "let" type blocks, "for-loop" type blocks, etc. +* `FieldGlobalFlydown` \- A field which is used to name (and rename) a global variable. It also provides a "flydown" which is displayed when the field is hovered over with a mouse (or pen); clicking the field edits the name. On a touch screen, where there is no hover, tapping the field opens the flydown and a second tap on the same field within `FieldFlydown.DOUBLE_TAP_TIMEOUT` milliseconds (500 by default) edits the name. The flydown enables the user to select getter or setter blocks corresponding to the global variable. This field is typically used as part of a block which defines a global variable. +* `FieldParameterFlydown` \- A somewhat poorly named field which is similar to the `FieldGlobalFlydown` (same hover/click and tap/double-tap gestures; the double-tap only edits if the field was created as editable), but is used for lexically scoped variables. It is typically used to define such variables in blocks that introduce lexical scopes, e.g. for the parameters of procedure/function (hence the "parameter" in the field name) definitions, "let" type blocks, "for-loop" type blocks, etc. * `FieldNoCheckDropdown` \- This is a field which is used by procedure call blocks to provide a dropdown of callable procedure names. Unfortunately, it is not currently completely usable by itself to do that. There is logic in the call blocks themselves which could (and should) be pulled out into a subclass of `FieldNoCheckDropdown` which could, more easily, be used to define your own call block(s). For the plugin fields to work properly, there are a set of methods that the blocks that they are part of need to diff --git a/block-lexical-variables/README.md b/block-lexical-variables/README.md index 3c8ff2a..7c3b1da 100644 --- a/block-lexical-variables/README.md +++ b/block-lexical-variables/README.md @@ -57,7 +57,8 @@ within the body of the block. ![A picture of a lexical variable block](readme-media/lexvar.png "Lexical variable") -While hovering over the variable name: +While hovering over the variable name (on a touch screen, tap it instead; a +second tap within half a second opens the rename editor): ![A picture of a lexical variable block with getter and setter blocks](readme-media/lexvar-with-flydown.png "Lexical variable with flydown") diff --git a/block-lexical-variables/package-lock.json b/block-lexical-variables/package-lock.json index 952c724..9f47bf8 100644 --- a/block-lexical-variables/package-lock.json +++ b/block-lexical-variables/package-lock.json @@ -1,19 +1,20 @@ { "name": "@mit-app-inventor/blockly-block-lexical-variables", - "version": "11.0.3", + "version": "11.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mit-app-inventor/blockly-block-lexical-variables", - "version": "11.0.3", + "version": "11.0.4", "license": "Apache-2.0", "devDependencies": { "@blockly/dev-scripts": "^4.0.9", "@blockly/dev-tools": "^8.1.2", "blockly": "^11.2.2", "chai": "^4.5.0", - "mocha": "^10.2.0" + "mocha": "^10.2.0", + "sinon": "^22.1.0" }, "engines": { "node": ">=8.17.0" @@ -338,6 +339,68 @@ "blockly": "^11.0.0" } }, + "node_modules/@blockly/dev-tools/node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@blockly/dev-tools/node_modules/@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@blockly/dev-tools/node_modules/@sinonjs/samsam": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.3.1.tgz", + "integrity": "sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@blockly/dev-tools/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@blockly/dev-tools/node_modules/sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "deprecated": "16.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "node_modules/@blockly/eslint-config": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@blockly/eslint-config/-/eslint-config-4.0.1.tgz", @@ -738,39 +801,53 @@ } }, "node_modules/@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } }, "node_modules/@sinonjs/fake-timers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", - "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^1.7.0" + "@sinonjs/commons": "^3.0.1" } }, "node_modules/@sinonjs/samsam": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.3.1.tgz", - "integrity": "sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-10.0.2.tgz", + "integrity": "sha512-8lVwD1Df1BmzoaOLhMcGGcz/Jyr5QY2KSB75/YK1QgKzoabTeLdIVyhXNZK9ojfSKSdirbXqdbsXXqP9/Ve8+A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^1.6.0", - "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" } }, "node_modules/@sinonjs/text-encoding": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", - "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", - "dev": true + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "deprecated": "Deprecated: no longer maintained and no longer used by Sinon packages. See\n https://github.com/sinonjs/nise/issues/243 for replacement details.", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -2467,10 +2544,11 @@ "license": "MIT" }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -4184,7 +4262,8 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", @@ -4370,7 +4449,8 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/kind-of": { "version": "6.0.3", @@ -4458,7 +4538,9 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "dev": true + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -4888,6 +4970,7 @@ "resolved": "https://registry.npmjs.org/nise/-/nise-4.1.0.tgz", "integrity": "sha512-eQMEmGN/8arp0xsvGoQ+B1qvSkR73B1nWSCh7nOt5neMCtwcQVYQGdzQMhcNscktTsWB54xnlSQFzOAPJD8nXA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^1.7.0", "@sinonjs/fake-timers": "^6.0.0", @@ -4896,6 +4979,26 @@ "path-to-regexp": "^1.7.0" } }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -5167,10 +5270,11 @@ "license": "MIT" }, "node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "dev": true, + "license": "MIT", "dependencies": { "isarray": "0.0.1" } @@ -5988,17 +6092,16 @@ } }, "node_modules/sinon": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", - "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.1.0.tgz", + "integrity": "sha512-n1ajF2rBWMTtEwbKcw4UdFg4nCnDdq/U6RDoxtOd7oapOlRoJ5ynwFx60owROyhDpA9QhMZi0pCO/xtmwFjG7w==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^1.8.1", - "@sinonjs/fake-timers": "^6.0.1", - "@sinonjs/samsam": "^5.3.1", - "diff": "^4.0.2", - "nise": "^4.0.4", - "supports-color": "^7.1.0" + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.4.0", + "@sinonjs/samsam": "^10.0.2", + "diff": "^9.0.0" }, "funding": { "type": "opencollective", diff --git a/block-lexical-variables/package.json b/block-lexical-variables/package.json index 63de691..0a18c93 100644 --- a/block-lexical-variables/package.json +++ b/block-lexical-variables/package.json @@ -1,6 +1,6 @@ { "name": "@mit-app-inventor/blockly-block-lexical-variables", - "version": "11.0.3", + "version": "11.0.4", "description": "A Blockly plugin providing lexical/local variable support and flydown variable getters and setters.", "scripts": { "audit:fix": "blockly-scripts auditFix", @@ -50,7 +50,8 @@ "@blockly/dev-tools": "^8.1.2", "blockly": "^11.2.2", "chai": "^4.5.0", - "mocha": "^10.2.0" + "mocha": "^10.2.0", + "sinon": "^22.1.0" }, "peerDependencies": { "blockly": "^11.2.2" diff --git a/block-lexical-variables/src/fields/field_flydown.js b/block-lexical-variables/src/fields/field_flydown.js index da88829..f5bb12f 100644 --- a/block-lexical-variables/src/fields/field_flydown.js +++ b/block-lexical-variables/src/fields/field_flydown.js @@ -8,6 +8,11 @@ * and mouse click edits the field name. * Flydowns are used in App Inventor for displaying get/set blocks for * parameter names and callers for procedure declarations. + * + * Touch screens have no hover, so a finger gets tap gestures instead: one tap + * opens the flydown and a second tap on the same field within + * FieldFlydown.DOUBLE_TAP_TIMEOUT opens the editor (if the field is editable). + * Mouse and pen input keep the hover/click behaviour. * @author fturbak@wellesley.edu (Lyn Turbak) */ @@ -57,6 +62,30 @@ export class FieldFlydown extends Blockly.FieldTextInput { */ static openFieldFlydown_ = null; + /** + * Maximum milliseconds between two touch taps on the same field for the + * second one to count as a double-tap. + * @type {number} + * @const + */ + static DOUBLE_TAP_TIMEOUT = 500; + + /** + * The field that received the most recent single touch tap, or null. + * Used with lastTapTime_ to detect a double-tap. Kept as a static like + * showPid_ because at most one flydown is open at a time. + * @type {FieldFlydown} + * @private + */ + static lastTapField_ = null; + + /** + * Time (ms since epoch) of the tap recorded in lastTapField_. + * @type {number} + * @private + */ + static lastTapTime_ = 0; + // These control the positions of the flydown. static DISPLAY_BELOW = 'BELOW'; static DISPLAY_RIGHT = 'RIGHT'; @@ -72,17 +101,78 @@ export class FieldFlydown extends Blockly.FieldTextInput { FieldFlydown.DISPLAY_RIGHT; }; - // Override FieldTextInput's showEditor_ so it's only called for EDITABLE field. - showEditor_() { - if (!this.EDITABLE) { + /** + * Handles a click (mouse/pen) or a tap (touch) on the field. + * + * Mouse and pen: the flydown is opened by hovering (see onMouseOver_), so a + * click only opens the editor, and only for EDITABLE fields. + * + * Touch: there is no hover, so the first tap opens the flydown and a second + * tap on the same field within DOUBLE_TAP_TIMEOUT opens the editor (if the + * field is EDITABLE; otherwise the second tap does nothing). Note that the + * pointerdown of every tap already hides any open flydown through + * hideChaff(), which is why the double-tap is detected with a timestamp + * rather than by looking at the flydown. + * + * @param {Event=} e The pointerup event that ended the click, as passed by + * Blockly.Field.showEditor. May be undefined when called directly. + * @override + */ + showEditor_(e) { + const block = this.getSourceBlock(); + if (!block || block.isInFlyout || !this.isTouchTap_(e)) { + // Mouse / pen / field in a flyout: click edits, hover opens the flydown. + if (!this.EDITABLE) { + return; + } + if (FieldFlydown.showPid_) { // cancel a pending flydown for editing + clearTimeout(FieldFlydown.showPid_); + FieldFlydown.showPid_ = 0; + Blockly.common.getMainWorkspace().hideChaff(); + } + super.showEditor_(e); return; } - if (FieldFlydown.showPid_) { // cancel a pending flydown for editing + + // Touch tap. A hover timer should never be armed for touch (see + // onMouseOver_), but make sure one can't fire underneath us. + if (FieldFlydown.showPid_) { clearTimeout(FieldFlydown.showPid_); FieldFlydown.showPid_ = 0; - Blockly.common.getMainWorkspace().hideChaff(); } - super.showEditor_(); + const now = Date.now(); + const isDoubleTap = FieldFlydown.lastTapField_ === this && + now - FieldFlydown.lastTapTime_ <= FieldFlydown.DOUBLE_TAP_TIMEOUT; + if (isDoubleTap) { + FieldFlydown.lastTapField_ = null; + FieldFlydown.lastTapTime_ = 0; + if (this.EDITABLE) { + Blockly.common.getMainWorkspace().hideChaff(); + super.showEditor_(e); + } + return; + } + FieldFlydown.lastTapField_ = this; + FieldFlydown.lastTapTime_ = now; + if (!block.workspace.isDragging()) { + try { + this.showFlydown_(); + } catch (err) { + console.error('Failed to show flydown', err); + } + } + }; + + /** + * Whether an event comes from a finger on a touch screen. Only 'touch' + * counts: a pen behaves like a mouse (hover opens, tap edits) so that pens + * which can hover keep working the way they do today. + * @param {Event=} e A pointer event, or undefined. + * @return {boolean} True for a touch tap. + * @private + */ + isTouchTap_(e) { + return !!e && e.pointerType === 'touch'; }; init(block) { @@ -123,7 +213,11 @@ FieldFlydown.prototype.flyoutCSSClassName = FieldFlydown.prototype.onMouseOver_ = function(e) { // [lyn, 10/22/13] No flydowns in a flyout! - if (!this.getSourceBlock().isInFlyout && FieldFlydown.showPid_ == 0) { + // A finger fires pointerover/pointerout around every tap, so the hover + // timer would be armed and cancelled uselessly; touch opens the flydown by + // tapping instead (see showEditor_). + if (!this.getSourceBlock().isInFlyout && FieldFlydown.showPid_ == 0 && + !this.isTouchTap_(e)) { FieldFlydown.showPid_ = window.setTimeout(this.showFlydownMaker_(), FieldFlydown.timeout); @@ -150,7 +244,7 @@ FieldFlydown.prototype.showFlydownMaker_ = function() { return function() { if (FieldFlydown.showPid_ !== 0 && !field.getSourceBlock().workspace.isDragging() && - !this.htmlInput_) { + !field.htmlInput_) { try { field.showFlydown_(); } catch (e) { @@ -286,6 +380,10 @@ FieldFlydown.prototype.dispose = function() { if (FieldFlydown.openFieldFlydown_ == this) { FieldFlydown.hide(); } + if (FieldFlydown.lastTapField_ === this) { + FieldFlydown.lastTapField_ = null; + FieldFlydown.lastTapTime_ = 0; + } // Call parent's destructor. Blockly.FieldTextInput.prototype.dispose.call(this); }; diff --git a/block-lexical-variables/test/field_flydown_touch.mocha.js b/block-lexical-variables/test/field_flydown_touch.mocha.js new file mode 100644 index 0000000..b2cffdc --- /dev/null +++ b/block-lexical-variables/test/field_flydown_touch.mocha.js @@ -0,0 +1,265 @@ +// Copyright © 2026 Massachusetts Institute of Technology. All rights reserved. + +/** + * @license + * @fileoverview Tests for the touch-screen gestures of FieldFlydown: one tap + * opens the flydown, a second tap within DOUBLE_TAP_TIMEOUT opens the editor + * when the field is editable. Mouse and pen keep hover-to-open / click-to-edit. + */ + +import * as Blockly from 'blockly/core'; +import * as libraryBlocks from 'blockly/blocks'; + +import '../src/msg'; +import '../src/utilities'; +import '../src/workspace'; +import '../src/procedure_utils'; +import '../src/fields/flydown'; +import {FieldFlydown} from '../src/fields/field_flydown'; +import '../src/fields/field_global_flydown'; +import '../src/fields/field_nocheck_dropdown'; +import '../src/fields/field_lexical_variable'; +import {FieldParameterFlydown} from '../src/fields/field_parameter_flydown'; +import '../src/fields/field_procedurename'; +import '../src/blocks/lexical-variables'; +import '../src/blocks/controls'; +import '../src/blocks/variable-get-set.js'; +import '../src/procedure_database'; +import '../src/blocks/procedures'; + +import chai from 'chai'; +import sinon from 'sinon'; + +const assert = chai.assert; + +/** A stand-in for the pointerup event Blockly.Field.showEditor passes on. */ +function pointer(pointerType) { + return {pointerType, stopPropagation() {}}; +} + +suite('FieldFlydown touch gestures', function() { + setup(function() { + // The plugin schedules the hover flydown with window.setTimeout; the node + // test bundle has no window, so alias it to the (faked) globals. + this.hadWindow = 'window' in globalThis; + if (!this.hadWindow) globalThis.window = globalThis; + this.clock = sinon.useFakeTimers( + {now: 1000000, toFake: ['setTimeout', 'clearTimeout', 'Date']}); + + // A headless workspace is enough: showFlydown_ (SVG geometry) is stubbed, + // and the two WorkspaceSvg methods the field touches are stood in for. + this.workspace = new Blockly.Workspace(); + this.workspace.isDragging = () => false; + this.workspace.hideChaff = sinon.spy(); + Blockly.common.setMainWorkspace(this.workspace); + + FieldFlydown.showPid_ = 0; + FieldFlydown.lastTapField_ = null; + FieldFlydown.lastTapTime_ = 0; + + // Editable field: the NAME of a global declaration (FieldGlobalFlydown). + this.globalBlock = this.workspace.newBlock('global_declaration'); + this.editable = this.globalBlock.getField('NAME'); + assert.instanceOf(this.editable, FieldFlydown); + assert.isTrue(this.editable.EDITABLE); + + // Non-editable field: a parameter flydown constructed with isEditable=false + // (what event-parameter fields use). + this.paramBlock = this.workspace.newBlock('text'); + this.readOnly = new FieldParameterFlydown('x', false); + this.paramBlock.appendDummyInput().appendField(this.readOnly, 'P'); + assert.isFalse(this.readOnly.EDITABLE); + + this.showFlydown = { + editable: sinon.stub(this.editable, 'showFlydown_'), + readOnly: sinon.stub(this.readOnly, 'showFlydown_'), + }; + this.editor = sinon.stub(Blockly.FieldTextInput.prototype, 'showEditor_'); + }); + + teardown(function() { + this.editor.restore(); + this.clock.restore(); + this.workspace.dispose(); + if (!this.hadWindow) delete globalThis.window; + FieldFlydown.showPid_ = 0; + FieldFlydown.lastTapField_ = null; + FieldFlydown.lastTapTime_ = 0; + }); + + suite('touch', function() { + test('a single tap opens the flydown, not the editor', function() { + this.editable.showEditor_(pointer('touch')); + assert.isTrue(this.showFlydown.editable.calledOnce); + assert.isTrue(this.editor.notCalled); + assert.strictEqual(FieldFlydown.lastTapField_, this.editable); + }); + + test('a second tap within the timeout opens the editor on an editable ' + + 'field', function() { + const first = pointer('touch'); + const second = pointer('touch'); + this.editable.showEditor_(first); + this.clock.tick(FieldFlydown.DOUBLE_TAP_TIMEOUT - 100); + this.editable.showEditor_(second); + assert.isTrue(this.editor.calledOnce); + assert.strictEqual(this.editor.firstCall.args[0], second); + assert.isTrue(this.showFlydown.editable.calledOnce, + 'only the first tap opens the flydown'); + assert.isTrue(this.workspace.hideChaff.called); + assert.isNull(FieldFlydown.lastTapField_); + assert.strictEqual(FieldFlydown.lastTapTime_, 0); + }); + + test('two taps further apart than the timeout are two single taps', + function() { + this.editable.showEditor_(pointer('touch')); + this.clock.tick(FieldFlydown.DOUBLE_TAP_TIMEOUT + 1); + this.editable.showEditor_(pointer('touch')); + assert.isTrue(this.showFlydown.editable.calledTwice); + assert.isTrue(this.editor.notCalled); + assert.strictEqual(FieldFlydown.lastTapField_, this.editable); + }); + + test('a second tap on a non-editable field does nothing', function() { + this.readOnly.showEditor_(pointer('touch')); + this.clock.tick(100); + this.readOnly.showEditor_(pointer('touch')); + assert.isTrue(this.showFlydown.readOnly.calledOnce, + 'only the first tap opens the flydown'); + assert.isTrue(this.editor.notCalled); + assert.isNull(FieldFlydown.lastTapField_); + }); + + test('a third tap on a non-editable field starts a new single-tap cycle', + function() { + this.readOnly.showEditor_(pointer('touch')); + this.clock.tick(100); + this.readOnly.showEditor_(pointer('touch')); + this.clock.tick(100); + this.readOnly.showEditor_(pointer('touch')); + assert.isTrue(this.showFlydown.readOnly.calledTwice); + assert.isTrue(this.editor.notCalled); + }); + + test('tapping a different field within the timeout is a single tap on it', + function() { + this.editable.showEditor_(pointer('touch')); + this.clock.tick(100); + this.readOnly.showEditor_(pointer('touch')); + assert.isTrue(this.showFlydown.editable.calledOnce); + assert.isTrue(this.showFlydown.readOnly.calledOnce); + assert.isTrue(this.editor.notCalled); + assert.strictEqual(FieldFlydown.lastTapField_, this.readOnly); + }); + + test('a tap does not open the flydown while the workspace is dragging', + function() { + this.workspace.isDragging = () => true; + this.editable.showEditor_(pointer('touch')); + assert.isTrue(this.showFlydown.editable.notCalled); + assert.isTrue(this.editor.notCalled); + }); + + test('a tap on a field inside a flyout falls back to click behaviour', + function() { + this.globalBlock.isInFlyout = true; + this.paramBlock.isInFlyout = true; + this.editable.showEditor_(pointer('touch')); + this.readOnly.showEditor_(pointer('touch')); + assert.isTrue(this.showFlydown.editable.notCalled); + assert.isTrue(this.showFlydown.readOnly.notCalled); + assert.isTrue(this.editor.calledOnce, + 'editable field still opens the editor'); + }); + + test('a tap cancels a pending hover timer', function() { + FieldFlydown.showPid_ = window.setTimeout(() => {}, FieldFlydown.timeout); + this.editable.showEditor_(pointer('touch')); + assert.strictEqual(FieldFlydown.showPid_, 0); + assert.isTrue(this.showFlydown.editable.calledOnce); + }); + + test('disposing the tapped field clears the tap state', function() { + this.editable.showEditor_(pointer('touch')); + assert.strictEqual(FieldFlydown.lastTapField_, this.editable); + this.globalBlock.dispose(); + assert.isNull(FieldFlydown.lastTapField_); + assert.strictEqual(FieldFlydown.lastTapTime_, 0); + }); + }); + + suite('mouse and pen keep the legacy behaviour', function() { + ['mouse', 'pen', undefined].forEach(function(pointerType) { + const label = pointerType || 'no pointerType'; + + test(`${label}: click edits an editable field, never opens the flydown`, + function() { + const e = pointer(pointerType); + this.editable.showEditor_(e); + assert.isTrue(this.editor.calledOnce); + assert.strictEqual(this.editor.firstCall.args[0], e); + assert.isTrue(this.showFlydown.editable.notCalled); + assert.isNull(FieldFlydown.lastTapField_); + }); + + test(`${label}: click on a non-editable field does nothing`, function() { + this.readOnly.showEditor_(pointer(pointerType)); + assert.isTrue(this.editor.notCalled); + assert.isTrue(this.showFlydown.readOnly.notCalled); + }); + }); + + test('a click cancels a pending hover timer before editing', + function() { + FieldFlydown.showPid_ = window.setTimeout(() => {}, FieldFlydown.timeout); + this.editable.showEditor_(pointer('mouse')); + assert.strictEqual(FieldFlydown.showPid_, 0); + assert.isTrue(this.workspace.hideChaff.calledOnce); + assert.isTrue(this.editor.calledOnce); + }); + }); + + suite('hover timer', function() { + test('mouse hover arms the timer and shows the flydown after the timeout', + function() { + this.editable.onMouseOver_(pointer('mouse')); + assert.notStrictEqual(FieldFlydown.showPid_, 0); + this.clock.tick(FieldFlydown.timeout); + assert.isTrue(this.showFlydown.editable.calledOnce); + assert.strictEqual(FieldFlydown.showPid_, 0); + }); + + test('pen hover behaves like mouse hover', function() { + this.editable.onMouseOver_(pointer('pen')); + assert.notStrictEqual(FieldFlydown.showPid_, 0); + this.clock.tick(FieldFlydown.timeout); + assert.isTrue(this.showFlydown.editable.calledOnce); + }); + + test('touch never arms the hover timer', function() { + this.editable.onMouseOver_(pointer('touch')); + assert.strictEqual(FieldFlydown.showPid_, 0); + this.clock.tick(FieldFlydown.timeout * 2); + assert.isTrue(this.showFlydown.editable.notCalled); + }); + + test('leaving before the timeout cancels the flydown', function() { + this.editable.onMouseOver_(pointer('mouse')); + this.clock.tick(FieldFlydown.timeout - 1); + this.editable.onMouseOut_(pointer('mouse')); + this.clock.tick(FieldFlydown.timeout); + assert.isTrue(this.showFlydown.editable.notCalled); + assert.strictEqual(FieldFlydown.showPid_, 0); + }); + + test('the hover timer does not show the flydown while the inline editor ' + + 'is open', function() { + this.editable.htmlInput_ = {}; + this.editable.onMouseOver_(pointer('mouse')); + this.clock.tick(FieldFlydown.timeout); + assert.isTrue(this.showFlydown.editable.notCalled); + delete this.editable.htmlInput_; + }); + }); +});