From b5dbe6acfbd1e29c7d7d3f97216850ae03b79955 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 09:59:42 -0700 Subject: [PATCH 01/67] chore(deps): bump @blockly/dev-tools from 9.0.0 to 9.0.1 (#9124) Bumps [@blockly/dev-tools](https://github.com/google/blockly-samples/tree/HEAD/plugins/dev-tools) from 9.0.0 to 9.0.1. - [Release notes](https://github.com/google/blockly-samples/releases) - [Changelog](https://github.com/google/blockly-samples/blob/master/plugins/dev-tools/CHANGELOG.md) - [Commits](https://github.com/google/blockly-samples/commits/@blockly/dev-tools@9.0.1/plugins/dev-tools) --- updated-dependencies: - dependency-name: "@blockly/dev-tools" dependency-version: 9.0.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index f313dcf8b12..d37ddd90c2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,17 +101,17 @@ } }, "node_modules/@blockly/dev-tools": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.0.tgz", - "integrity": "sha512-c2JJbj5Q9mGdy0iUvE5OBOl1zmSMJrSokORgnmrhxGCiJ6QexPGCsi1QAn6uzpUtGKjhpnEAQ6+jX7ROZe7QQg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.1.tgz", + "integrity": "sha512-OnY24Up00owts0VtOaokUmOQdzH+K1PNcr3LC3huwa9PO0TlKiXTq4V5OuIqBS++enyj93gXQ8PhvFGudkogTQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@blockly/block-test": "^7.0.0", - "@blockly/theme-dark": "^8.0.0", - "@blockly/theme-deuteranopia": "^7.0.0", - "@blockly/theme-highcontrast": "^7.0.0", - "@blockly/theme-tritanopia": "^7.0.0", + "@blockly/block-test": "^7.0.1", + "@blockly/theme-dark": "^8.0.1", + "@blockly/theme-deuteranopia": "^7.0.1", + "@blockly/theme-highcontrast": "^7.0.1", + "@blockly/theme-tritanopia": "^7.0.1", "chai": "^4.2.0", "dat.gui": "^0.7.7", "lodash.assign": "^4.2.0", @@ -127,9 +127,9 @@ } }, "node_modules/@blockly/dev-tools/node_modules/@blockly/block-test": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.0.tgz", - "integrity": "sha512-Y+Iwg1hHmOaqXveTOiZNXHH+jNBP+LC5L8ZxKKWeO8aB9DZD5G2hgApHfLaxeZzqnCl8zspvGnrrlFy9foEdWw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.1.tgz", + "integrity": "sha512-w91ZZbpJDKGQJVO7gKqQaM17ffcsW1ktrnSTz/OpDw5R4H+1q05NgWO5gYzGPzLfFdvPcrkc0v00KhD4UG7BRA==", "dev": true, "license": "Apache 2.0", "engines": { @@ -209,9 +209,9 @@ } }, "node_modules/@blockly/theme-dark": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-8.0.0.tgz", - "integrity": "sha512-Fq8ifjCwbJW305Su7SNBP8jXs4h1hp2EdQ9cMGOCr/racRIYfDRRBqjy0ZRLLqI7BsgZKxKy6Aa+OjgWEKeKfw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-8.0.1.tgz", + "integrity": "sha512-0Di3WIUwCVQw7jK9myUf/J+4oHLADWc8YxeF40KQgGsyulVrVnYipwtBolj+wxq2xjxIkqgvctAN3BdvM4mynA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -222,9 +222,9 @@ } }, "node_modules/@blockly/theme-deuteranopia": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-7.0.0.tgz", - "integrity": "sha512-zKhlnD/AF3MR9+Rlwus3vAPq8gwCZaZ08VEupvz5b98mk36suRlIrQanM8HVLGcozxiEvUNrTNOGO5kj8PeTWA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-7.0.1.tgz", + "integrity": "sha512-V05Hk2hzQZict47LfzDdSTP+J5HlYiF7de/8LR/bsRQB/ft7UUTraqDLIivYc9gL2alsVtKzq/yFs9wi7FMAqQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -235,9 +235,9 @@ } }, "node_modules/@blockly/theme-highcontrast": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-7.0.0.tgz", - "integrity": "sha512-6Apkw5iUlOq1DoOJgwsfo8Iha2OkxXMSNHqb8ZVVmUhCHjce0XMXgq1Rqty/2l/C2AKB+WWLZEWxOyGWYrQViQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-7.0.1.tgz", + "integrity": "sha512-dMhysbXf8QtHxuhI1EY5GdZErlfEhjpCogwfzglDKSu8MF2C+5qzOQBxKmqfnEYJl6G9B2HNGw+mEaUo8oel6Q==", "dev": true, "license": "Apache-2.0", "engines": { @@ -260,9 +260,9 @@ } }, "node_modules/@blockly/theme-tritanopia": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-7.0.0.tgz", - "integrity": "sha512-22TFAuY8ilKsQomDC8GXMHsCfdR8l75yPPFl6AOCcok2FJLkiyhjGpAy2cNexA9P2xP/rW7vdsG3wC8ukWihUA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-7.0.1.tgz", + "integrity": "sha512-eLqPCmW6xvSYvyTFFE5uz0Bw806LxOmaQrCOzbUywkT41s2ITP06OP1BVQrHdkZSt5whipZYpB1RMGxYxS/Bpw==", "dev": true, "license": "Apache-2.0", "engines": { From 2f7ece86ff6a6aeeb99d45a138a2a359b1dff5b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 06:46:53 +0000 Subject: [PATCH 02/67] chore(deps-dev): bump tar-fs Bumps the npm_and_yarn group with 1 update in the / directory: [tar-fs](https://github.com/mafintosh/tar-fs). Updates `tar-fs` from 3.0.8 to 3.0.9 - [Commits](https://github.com/mafintosh/tar-fs/compare/v3.0.8...v3.0.9) --- updated-dependencies: - dependency-name: tar-fs dependency-version: 3.0.9 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d37ddd90c2e..f622d64df76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9588,9 +9588,9 @@ "dev": true }, "node_modules/tar-fs": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", - "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", + "integrity": "sha512-XF4w9Xp+ZQgifKakjZYmFdkLoSWd34VGKcsTCwlNWM7QG3ZbaxnTsaBwnjFZqHRf/rROxaR8rXnbtwdvaDI+lA==", "dev": true, "license": "MIT", "dependencies": { From 10085693e5512afedee36dd9192224d894a43494 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Jun 2025 09:48:50 +0000 Subject: [PATCH 03/67] chore(deps): bump eslint-plugin-jsdoc from 50.6.9 to 50.7.1 Bumps [eslint-plugin-jsdoc](https://github.com/gajus/eslint-plugin-jsdoc) from 50.6.9 to 50.7.1. - [Release notes](https://github.com/gajus/eslint-plugin-jsdoc/releases) - [Changelog](https://github.com/gajus/eslint-plugin-jsdoc/blob/main/.releaserc) - [Commits](https://github.com/gajus/eslint-plugin-jsdoc/compare/v50.6.9...v50.7.1) --- updated-dependencies: - dependency-name: eslint-plugin-jsdoc dependency-version: 50.7.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 114 ++++++++++++++++------------------------------ 1 file changed, 39 insertions(+), 75 deletions(-) diff --git a/package-lock.json b/package-lock.json index f622d64df76..40c20bd0d21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -383,17 +383,20 @@ } }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.49.0", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz", - "integrity": "sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==", + "version": "0.50.2", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz", + "integrity": "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==", "dev": true, + "license": "MIT", "dependencies": { + "@types/estree": "^1.0.6", + "@typescript-eslint/types": "^8.11.0", "comment-parser": "1.4.1", "esquery": "^1.6.0", "jsdoc-type-pratt-parser": "~4.1.0" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1211,18 +1214,6 @@ "node": ">=14" } }, - "node_modules/@pkgr/core": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", - "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, "node_modules/@promptbook/utils": { "version": "0.69.5", "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz", @@ -3176,6 +3167,7 @@ "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 12.0.0" } @@ -3693,9 +3685,10 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", "dependencies": { "ms": "^2.1.3" }, @@ -4088,12 +4081,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", - "dev": true - }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4301,23 +4288,22 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "50.6.9", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.6.9.tgz", - "integrity": "sha512-7/nHu3FWD4QRG8tCVqcv+BfFtctUtEDWc29oeDXB4bwmDM2/r1ndl14AG/2DUntdqH7qmpvdemJKwb3R97/QEw==", + "version": "50.7.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.7.1.tgz", + "integrity": "sha512-XBnVA5g2kUVokTNUiE1McEPse5n9/mNUmuJcx52psT6zBs2eVcXSmQBvjfa7NZdfLVSy3u1pEDDUxoxpwy89WA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.49.0", + "@es-joy/jsdoccomment": "~0.50.2", "are-docs-informative": "^0.0.2", "comment-parser": "1.4.1", - "debug": "^4.3.6", + "debug": "^4.4.1", "escape-string-regexp": "^4.0.0", - "espree": "^10.1.0", + "espree": "^10.3.0", "esquery": "^1.6.0", - "parse-imports": "^2.1.1", - "semver": "^7.6.3", - "spdx-expression-parse": "^4.0.0", - "synckit": "^0.9.1" + "parse-imports-exports": "^0.2.4", + "semver": "^7.7.2", + "spdx-expression-parse": "^4.0.0" }, "engines": { "node": ">=18" @@ -4327,10 +4313,11 @@ } }, "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -6661,6 +6648,7 @@ "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.0.0" } @@ -7855,17 +7843,14 @@ "node": ">=0.8" } }, - "node_modules/parse-imports": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/parse-imports/-/parse-imports-2.2.1.tgz", - "integrity": "sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==", + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", "dev": true, + "license": "MIT", "dependencies": { - "es-module-lexer": "^1.5.3", - "slashes": "^3.0.12" - }, - "engines": { - "node": ">= 18" + "parse-statements": "1.0.11" } }, "node_modules/parse-node-version": { @@ -7886,6 +7871,13 @@ "node": ">=0.10.0" } }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, "node_modules/parse5": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", @@ -9186,12 +9178,6 @@ "node": ">=0.3.1" } }, - "node_modules/slashes": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz", - "integrity": "sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==", - "dev": true - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -9565,28 +9551,6 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, - "node_modules/synckit": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", - "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", - "dev": true, - "dependencies": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/unts" - } - }, - "node_modules/synckit/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true - }, "node_modules/tar-fs": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", From 9685498d219aa796a5e1a1bfafb3cf64834625e5 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Mon, 9 Jun 2025 15:13:43 -0700 Subject: [PATCH 04/67] Add isCopyable and isCuttable as optional methods on ICopyable --- core/block_svg.ts | 10 +++ core/comments/rendered_workspace_comment.ts | 10 +++ core/interfaces/i_copyable.ts | 16 ++++- core/shortcut_items.ts | 77 ++++++++------------- 4 files changed, 62 insertions(+), 51 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 8ea26e354ef..501be1b596f 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -1721,6 +1721,16 @@ export class BlockSvg this.dragStrategy = dragStrategy; } + /** Returns whether this block is copyable or not. */ + isCopyable(): boolean { + return this.isOwnDeletable() && this.isOwnMovable(); + } + + /** Returns whether this block is cuttable or not. */ + isCuttable(): boolean { + return this.isDeletable() && this.isMovable(); + } + /** Returns whether this block is movable or not. */ override isMovable(): boolean { return this.dragStrategy.isMovable(); diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 3a3d57a441d..569905518bc 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -244,6 +244,16 @@ export class RenderedWorkspaceComment } } + /** Returns whether this comment is copyable or not */ + isCopyable(): boolean { + return this.isOwnMovable() && this.isOwnDeletable(); + } + + /** Returns whether this comment is cuttable or not */ + isCuttable(): boolean { + return this.isMovable() && this.isDeletable(); + } + /** Returns whether this comment is movable or not. */ isMovable(): boolean { return this.dragStrategy.isMovable(); diff --git a/core/interfaces/i_copyable.ts b/core/interfaces/i_copyable.ts index b653bd20a10..4f5e4ab9af4 100644 --- a/core/interfaces/i_copyable.ts +++ b/core/interfaces/i_copyable.ts @@ -15,6 +15,20 @@ export interface ICopyable extends ISelectable { * @returns Copy metadata. */ toCopyData(): T | null; + + /** + * Whether this instance is currently copyable. + * + * @returns True if it can currently be copied. + */ + isCopyable?(): boolean; + + /** + * Whether this instance is currently cuttable. + * + * @returns True if it can currently be cut. + */ + isCuttable?(): boolean; } export namespace ICopyable { @@ -25,7 +39,7 @@ export namespace ICopyable { export type ICopyData = ICopyable.ICopyData; -/** @returns true if the given object is copyable. */ +/** @returns true if the given object is an ICopyable. */ export function isCopyable(obj: any): obj is ICopyable { return obj.toCopyData !== undefined; } diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 161d5fceb13..0da30de6222 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -8,6 +8,7 @@ import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; +import { RenderedWorkspaceComment } from './comments.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; @@ -106,68 +107,44 @@ let copyCoords: Coordinate | null = null; /** * Determine if a focusable node can be copied. * - * Unfortunately the ICopyable interface doesn't include an isCopyable - * method, so we must use some other criteria to make the decision. - * Specifically, - * - * - It must be an ICopyable. - * - So that a pasted copy can be manipluated and/or disposed of, it - * must be both an IDraggable and an IDeletable. - * - Additionally, both .isOwnMovable() and .isOwnDeletable() must return - * true (i.e., the copy could be moved and deleted). - * - * TODO(#9098): Revise these criteria. The latter criteria prevents - * shadow blocks from being copied; additionally, there are likely to - * be other circumstances were it is desirable to allow movable / - * copyable copies of a currently-unmovable / -copyable block to be - * made. + * This will use the isCopyable method if the node implements it, otherwise + * it will fall back to checking if the node is deletable and draggable not + * considering the workspace's edit state. * * @param focused The focused object. */ -function isCopyable( - focused: IFocusableNode, -): focused is ICopyable & IDeletable & IDraggable { - if (!(focused instanceof BlockSvg)) return false; - return ( - isICopyable(focused) && - isIDeletable(focused) && - focused.isOwnDeletable() && - isDraggable(focused) && - focused.isOwnMovable() - ); +function isCopyable(focused: IFocusableNode): boolean { + if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused)) + return false; + if (focused.isCopyable !== undefined) { + return focused.isCopyable(); + } else if ( + focused instanceof BlockSvg || + focused instanceof RenderedWorkspaceComment + ) { + return focused.isOwnDeletable() && focused.isOwnMovable(); + } + // This isn't a class Blockly knows about, so fall back to the stricter + // checks for deletable and movable. + return focused.isDeletable() && focused.isMovable(); } /** * Determine if a focusable node can be cut. * - * Unfortunately the ICopyable interface doesn't include an isCuttable - * method, so we must use some other criteria to make the decision. - * Specifically, - * - * - It must be an ICopyable. - * - So that a pasted copy can be manipluated and/or disposed of, it - * must be both an IDraggable and an IDeletable. - * - Additionally, both .isMovable() and .isDeletable() must return - * true (i.e., can currently be moved and deleted). This is the main - * difference with isCopyable. - * - * TODO(#9098): Revise these criteria. The latter criteria prevents - * shadow blocks from being copied; additionally, there are likely to - * be other circumstances were it is desirable to allow movable / - * copyable copies of a currently-unmovable / -copyable block to be - * made. + * This will use the isCuttable method if the node implements it, otherwise + * it will fall back to checking if the node can be moved and deleted in its + * current workspace. * * @param focused The focused object. */ function isCuttable(focused: IFocusableNode): boolean { - if (!(focused instanceof BlockSvg)) return false; - return ( - isICopyable(focused) && - isIDeletable(focused) && - focused.isDeletable() && - isDraggable(focused) && - focused.isMovable() - ); + if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused)) + return false; + if (focused.isCuttable !== undefined) { + return focused.isCuttable(); + } + return focused.isMovable() && focused.isDeletable(); } /** From 46078369c2e61dd9040d5a8bb9c4598927409e96 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Mon, 9 Jun 2025 15:33:45 -0700 Subject: [PATCH 05/67] Fix build errors --- core/shortcut_items.ts | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 0da30de6222..8d67321d7d7 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -8,20 +8,13 @@ import {BlockSvg} from './block_svg.js'; import * as clipboard from './clipboard.js'; -import { RenderedWorkspaceComment } from './comments.js'; +import {RenderedWorkspaceComment} from './comments.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; -import { - ICopyable, - ICopyData, - isCopyable as isICopyable, -} from './interfaces/i_copyable.js'; -import { - IDeletable, - isDeletable as isIDeletable, -} from './interfaces/i_deletable.js'; -import {IDraggable, isDraggable} from './interfaces/i_draggable.js'; +import {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.js'; +import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; +import {isDraggable} from './interfaces/i_draggable.js'; import {IFocusableNode} from './interfaces/i_focusable_node.js'; import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js'; import {Coordinate} from './utils/coordinate.js'; @@ -182,17 +175,27 @@ export function registerCopy() { e.preventDefault(); const focused = scope.focusedNode; - if (!focused || !isCopyable(focused)) return false; - let targetWorkspace: WorkspaceSvg | null = - focused.workspace instanceof WorkspaceSvg - ? focused.workspace + if (!focused || !isICopyable(focused) || !isCopyable(focused)) + return false; + let targetWorkspace: WorkspaceSvg | null; + let hideChaff = false; + if (focused instanceof BlockSvg) { + hideChaff = !focused.workspace.isFlyout; + targetWorkspace = + focused.workspace instanceof WorkspaceSvg + ? focused.workspace + : workspace; + targetWorkspace = targetWorkspace.isFlyout + ? targetWorkspace.targetWorkspace + : targetWorkspace; + } else { + targetWorkspace = workspace.isFlyout + ? workspace.targetWorkspace : workspace; - targetWorkspace = targetWorkspace.isFlyout - ? targetWorkspace.targetWorkspace - : targetWorkspace; + } if (!targetWorkspace) return false; - if (!focused.workspace.isFlyout) { + if (hideChaff) { targetWorkspace.hideChaff(); } copyData = focused.toCopyData(); From e1441d5308b668f3c6e73b43a51e1a4176ce63f4 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Tue, 10 Jun 2025 11:09:12 -0700 Subject: [PATCH 06/67] Remove isCuttable api --- core/block_svg.ts | 5 ----- core/comments/rendered_workspace_comment.ts | 5 ----- core/interfaces/i_copyable.ts | 7 ------- core/shortcut_items.ts | 12 +++--------- 4 files changed, 3 insertions(+), 26 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index 501be1b596f..a30cc34ed9c 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -1726,11 +1726,6 @@ export class BlockSvg return this.isOwnDeletable() && this.isOwnMovable(); } - /** Returns whether this block is cuttable or not. */ - isCuttable(): boolean { - return this.isDeletable() && this.isMovable(); - } - /** Returns whether this block is movable or not. */ override isMovable(): boolean { return this.dragStrategy.isMovable(); diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 569905518bc..42fb1fda47c 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -249,11 +249,6 @@ export class RenderedWorkspaceComment return this.isOwnMovable() && this.isOwnDeletable(); } - /** Returns whether this comment is cuttable or not */ - isCuttable(): boolean { - return this.isMovable() && this.isDeletable(); - } - /** Returns whether this comment is movable or not. */ isMovable(): boolean { return this.dragStrategy.isMovable(); diff --git a/core/interfaces/i_copyable.ts b/core/interfaces/i_copyable.ts index 4f5e4ab9af4..246dd4dd509 100644 --- a/core/interfaces/i_copyable.ts +++ b/core/interfaces/i_copyable.ts @@ -22,13 +22,6 @@ export interface ICopyable extends ISelectable { * @returns True if it can currently be copied. */ isCopyable?(): boolean; - - /** - * Whether this instance is currently cuttable. - * - * @returns True if it can currently be cut. - */ - isCuttable?(): boolean; } export namespace ICopyable { diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 8d67321d7d7..302308fd637 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -125,19 +125,13 @@ function isCopyable(focused: IFocusableNode): boolean { /** * Determine if a focusable node can be cut. * - * This will use the isCuttable method if the node implements it, otherwise - * it will fall back to checking if the node can be moved and deleted in its - * current workspace. + * This will check if the node can be both copied and deleted in its current + * workspace. * * @param focused The focused object. */ function isCuttable(focused: IFocusableNode): boolean { - if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused)) - return false; - if (focused.isCuttable !== undefined) { - return focused.isCuttable(); - } - return focused.isMovable() && focused.isDeletable(); + return isCopyable(focused) && isIDeletable(focused) && focused.isDeletable(); } /** From 1d4e531ebed00e1d3e210e2c1ab6c93244fb3d4e Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Tue, 10 Jun 2025 11:24:42 -0700 Subject: [PATCH 07/67] Don't allow things in a flyout to be deleted or moved. --- core/block.ts | 2 ++ core/comments/workspace_comment.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/core/block.ts b/core/block.ts index 43bc6bbc5ed..9f7c11d4faf 100644 --- a/core/block.ts +++ b/core/block.ts @@ -791,6 +791,7 @@ export class Block { isDeletable(): boolean { return ( this.deletable && + !this.isInFlyout && !this.shadow && !this.isDeadOrDying() && !this.workspace.isReadOnly() @@ -824,6 +825,7 @@ export class Block { isMovable(): boolean { return ( this.movable && + !this.isInFlyout && !this.shadow && !this.isDeadOrDying() && !this.workspace.isReadOnly() diff --git a/core/comments/workspace_comment.ts b/core/comments/workspace_comment.ts index 190efd64dd1..b5dc3023cfe 100644 --- a/core/comments/workspace_comment.ts +++ b/core/comments/workspace_comment.ts @@ -165,7 +165,11 @@ export class WorkspaceComment { * workspace is read-only. */ isMovable() { - return this.isOwnMovable() && !this.workspace.isReadOnly(); + return ( + this.isOwnMovable() && + !this.workspace.isReadOnly() && + !this.workspace.isFlyout + ); } /** @@ -189,7 +193,8 @@ export class WorkspaceComment { return ( this.isOwnDeletable() && !this.isDeadOrDying() && - !this.workspace.isReadOnly() + !this.workspace.isReadOnly() && + !this.workspace.isFlyout ); } From 428e4475bfce90b1bc5d050d95b4535f160a40cc Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Tue, 10 Jun 2025 13:32:36 -0700 Subject: [PATCH 08/67] Simplify cut/copy logic --- core/shortcut_items.ts | 48 ++++++++++++------------------------------ 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 302308fd637..826cef28528 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -149,7 +149,6 @@ export function registerCopy() { name: names.COPY, preconditionFn(workspace, scope) { const focused = scope.focusedNode; - if (!(focused instanceof BlockSvg)) return false; const targetWorkspace = workspace.isFlyout ? workspace.targetWorkspace @@ -171,25 +170,12 @@ export function registerCopy() { const focused = scope.focusedNode; if (!focused || !isICopyable(focused) || !isCopyable(focused)) return false; - let targetWorkspace: WorkspaceSvg | null; - let hideChaff = false; - if (focused instanceof BlockSvg) { - hideChaff = !focused.workspace.isFlyout; - targetWorkspace = - focused.workspace instanceof WorkspaceSvg - ? focused.workspace - : workspace; - targetWorkspace = targetWorkspace.isFlyout - ? targetWorkspace.targetWorkspace - : targetWorkspace; - } else { - targetWorkspace = workspace.isFlyout - ? workspace.targetWorkspace - : workspace; - } + const targetWorkspace = workspace.isFlyout + ? workspace.targetWorkspace + : workspace; if (!targetWorkspace) return false; - if (hideChaff) { + if (focused.workspace.isFlyout) { targetWorkspace.hideChaff(); } copyData = focused.toCopyData(); @@ -230,27 +216,21 @@ export function registerCut() { }, callback(workspace, e, shortcut, scope) { const focused = scope.focusedNode; + if (!focused || !isCuttable(focused) || !isICopyable(focused)) { + return false; + } + copyData = focused.toCopyData(); + copyWorkspace = workspace; + copyCoords = isDraggable(focused) + ? focused.getRelativeToSurfaceXY() + : null; if (focused instanceof BlockSvg) { - copyData = focused.toCopyData(); - copyWorkspace = workspace; - copyCoords = focused.getRelativeToSurfaceXY(); focused.checkAndDelete(); - return true; - } else if ( - isIDeletable(focused) && - focused.isDeletable() && - isICopyable(focused) - ) { - copyData = focused.toCopyData(); - copyWorkspace = workspace; - copyCoords = isDraggable(focused) - ? focused.getRelativeToSurfaceXY() - : null; + } else if (isIDeletable(focused)) { focused.dispose(); - return true; } - return false; + return !!copyData; }, keyCodes: [ctrlX, metaX], }; From f1b44db6f4925b8880ba2182cb73d2cf05f68cce Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Tue, 10 Jun 2025 13:52:14 -0700 Subject: [PATCH 09/67] Add missing bang --- core/shortcut_items.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 826cef28528..615f1edc291 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -175,7 +175,7 @@ export function registerCopy() { : workspace; if (!targetWorkspace) return false; - if (focused.workspace.isFlyout) { + if (!focused.workspace.isFlyout) { targetWorkspace.hideChaff(); } copyData = focused.toCopyData(); From 32bb84ec8fbb9a23295ce5bb6328548ce55e3db2 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Fri, 13 Jun 2025 11:57:03 -0700 Subject: [PATCH 10/67] Allow copying from readonly workspace and add cut tests Also cleans up logic a bit --- core/shortcut_items.ts | 22 +++--- tests/mocha/shortcut_items_test.js | 123 ++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 14 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 615f1edc291..dca2d73668f 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -94,7 +94,6 @@ export function registerDelete() { } let copyData: ICopyData | null = null; -let copyWorkspace: WorkspaceSvg | null = null; let copyCoords: Coordinate | null = null; /** @@ -156,7 +155,6 @@ export function registerCopy() { return ( !!focused && !!targetWorkspace && - !targetWorkspace.isReadOnly() && !targetWorkspace.isDragging() && !getFocusManager().ephemeralFocusTaken() && isCopyable(focused) @@ -179,7 +177,6 @@ export function registerCopy() { targetWorkspace.hideChaff(); } copyData = focused.toCopyData(); - copyWorkspace = targetWorkspace; copyCoords = isDraggable(focused) && focused.workspace == targetWorkspace ? focused.getRelativeToSurfaceXY() @@ -220,7 +217,6 @@ export function registerCut() { return false; } copyData = focused.toCopyData(); - copyWorkspace = workspace; copyCoords = isDraggable(focused) ? focused.getRelativeToSurfaceXY() : null; @@ -264,7 +260,11 @@ export function registerPaste() { ); }, callback(workspace: WorkspaceSvg, e: Event) { - if (!copyData || !copyWorkspace) return false; + if (!copyData) return false; + const targetWorkspace = workspace.isFlyout + ? workspace.targetWorkspace + : workspace; + if (!targetWorkspace || targetWorkspace.isReadOnly()) return false; if (e instanceof PointerEvent) { // The event that triggers a shortcut would conventionally be a KeyboardEvent. @@ -273,19 +273,19 @@ export function registerPaste() { // at the mouse coordinates where the menu was opened, and this PointerEvent // is where the menu was opened. const mouseCoords = svgMath.screenToWsCoordinates( - copyWorkspace, + targetWorkspace, new Coordinate(e.clientX, e.clientY), ); - return !!clipboard.paste(copyData, copyWorkspace, mouseCoords); + return !!clipboard.paste(copyData, targetWorkspace, mouseCoords); } if (!copyCoords) { // If we don't have location data about the original copyable, let the // paster determine position. - return !!clipboard.paste(copyData, copyWorkspace); + return !!clipboard.paste(copyData, targetWorkspace); } - const {left, top, width, height} = copyWorkspace + const {left, top, width, height} = targetWorkspace .getMetricsManager() .getViewMetrics(true); const viewportRect = new Rect(top, top + height, left, left + width); @@ -293,12 +293,12 @@ export function registerPaste() { if (viewportRect.contains(copyCoords.x, copyCoords.y)) { // If the original copyable is inside the viewport, let the paster // determine position. - return !!clipboard.paste(copyData, copyWorkspace); + return !!clipboard.paste(copyData, targetWorkspace); } // Otherwise, paste in the middle of the viewport. const centerCoords = new Coordinate(left + width / 2, top + height / 2); - return !!clipboard.paste(copyData, copyWorkspace, centerCoords); + return !!clipboard.paste(copyData, targetWorkspace, centerCoords); }, keyCodes: [ctrlV, metaV], }; diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index 622df9efcf9..7667ba3879e 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -173,12 +173,17 @@ suite('Keyboard Shortcut Items', function () { }); }); }); - // Do not copy a block if a workspace is in readonly mode. - suite('Not called when readOnly is true', function () { + // Allow copying a block if a workspace is in readonly mode. + suite('Called when readOnly is true', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; const keyEvent = testCase[1]; - runReadOnlyTest(keyEvent, testCaseName); + test(testCaseName, function () { + this.workspace.setIsReadOnly(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.hideChaffSpy); + }); }); }); // Do not copy a block if a drag is in progress. @@ -238,6 +243,118 @@ suite('Keyboard Shortcut Items', function () { }); }); + suite('Cut', function () { + setup(function () { + this.block = setSelectedBlock(this.workspace); + this.copySpy = sinon.spy(this.block, 'toCopyData'); + this.disposeSpy = sinon.spy(this.block, 'dispose'); + this.hideChaffSpy = sinon.spy( + Blockly.WorkspaceSvg.prototype, + 'hideChaff', + ); + }); + const testCases = [ + [ + 'Control X', + createKeyDownEvent(Blockly.utils.KeyCodes.X, [ + Blockly.utils.KeyCodes.CTRL, + ]), + ], + [ + 'Meta X', + createKeyDownEvent(Blockly.utils.KeyCodes.X, [ + Blockly.utils.KeyCodes.META, + ]), + ], + ]; + // Cut a block. + suite('Simple', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.disposeSpy); + sinon.assert.calledOnce(this.hideChaffSpy); + }); + }); + }); + // Do not cut a block if a workspace is in readonly mode. + suite('Not called when readOnly is true', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + this.workspace.setIsReadOnly(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + }); + // Do not cut a block if a drag is in progress. + suite('Drag in progress', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + sinon.stub(this.workspace, 'isDragging').returns(true); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + }); + // Do not cut a block if is is not deletable. + suite('Block is not deletable', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + sinon + .stub(Blockly.common.getSelected(), 'isOwnDeletable') + .returns(false); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + }); + // Do not cut a block if it is not movable. + suite('Block is not movable', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + sinon + .stub(Blockly.common.getSelected(), 'isOwnMovable') + .returns(false); + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + }); + test('Not called when connection is focused', function () { + // Restore the stub behavior called during setup + Blockly.getFocusManager().getFocusedNode.restore(); + + setSelectedConnection(this.workspace); + const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [ + Blockly.utils.KeyCodes.CTRL, + ]); + this.injectionDiv.dispatchEvent(event); + sinon.assert.notCalled(this.copySpy); + sinon.assert.notCalled(this.disposeSpy); + sinon.assert.notCalled(this.hideChaffSpy); + }); + }); + suite('Undo', function () { setup(function () { this.undoSpy = sinon.spy(this.workspace, 'undo'); From fd5a7f4a1822908f160532917773255fcb5d3704 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 13 Jun 2025 12:05:00 -0700 Subject: [PATCH 11/67] refactor: Make the cursor use the focus manager for tracking the current node. (#9142) --- core/keyboard_nav/line_cursor.ts | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 85c0f414a07..89668dedb49 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -17,7 +17,6 @@ import {BlockSvg} from '../block_svg.js'; import {Field} from '../field.js'; import {getFocusManager} from '../focus_manager.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; -import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import * as registry from '../registry.js'; import {WorkspaceSvg} from '../workspace_svg.js'; import {Marker} from './marker.js'; @@ -374,17 +373,8 @@ export class LineCursor extends Marker { * * @returns The current field, connection, or block the cursor is on. */ - override getCurNode(): IFocusableNode | null { - // Ensure the current node matches what's currently focused. - const focused = getFocusManager().getFocusedNode(); - const block = this.getSourceBlockFromNode(focused); - if (block && block.workspace === this.workspace) { - // If the current focused node corresponds to a block then ensure that it - // belongs to the correct workspace for this cursor. - this.setCurNode(focused); - } - - return super.getCurNode(); + getCurNode(): IFocusableNode | null { + return getFocusManager().getFocusedNode(); } /** @@ -395,12 +385,8 @@ export class LineCursor extends Marker { * * @param newNode The new location of the cursor. */ - override setCurNode(newNode: IFocusableNode | null) { - super.setCurNode(newNode); - - if (isFocusableNode(newNode)) { - getFocusManager().focusNode(newNode); - } + setCurNode(newNode: IFocusableNode) { + getFocusManager().focusNode(newNode); // Try to scroll cursor into view. if (newNode instanceof BlockSvg) { From a88836227c4f0aac1dc9682d55fa2676e7a485f5 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Fri, 13 Jun 2025 13:07:53 -0700 Subject: [PATCH 12/67] Add tests for workspace comments --- tests/mocha/shortcut_items_test.js | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index 7667ba3879e..4ab83d8e1e2 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -47,6 +47,16 @@ suite('Keyboard Shortcut Items', function () { .returns(block.nextConnection); } + /** + * Creates a workspace comment and set it as the focused node. + * @param {Blockly.Workspace} workspace The workspace to create a new comment on. + */ + function setSelectedComment(workspace) { + const comment = workspace.newComment(); + sinon.stub(Blockly.getFocusManager(), 'getFocusedNode').returns(comment); + return comment; + } + /** * Creates a test for not running keyDown events when the workspace is in read only mode. * @param {Object} keyEvent Mocked key down event. Use createKeyDownEvent. @@ -241,6 +251,22 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.notCalled(this.copySpy); sinon.assert.notCalled(this.hideChaffSpy); }); + // Copy a comment. + test('Workspace comment', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + Blockly.getFocusManager().getFocusedNode.restore(); + this.comment = setSelectedComment(this.workspace); + this.copySpy = sinon.spy(this.comment, 'toCopyData'); + + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.hideChaffSpy); + }); + }); + }); }); suite('Cut', function () { @@ -353,6 +379,24 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.notCalled(this.disposeSpy); sinon.assert.notCalled(this.hideChaffSpy); }); + + // Cut a comment. + suite('Workspace comment', function () { + testCases.forEach(function (testCase) { + const testCaseName = testCase[0]; + const keyEvent = testCase[1]; + test(testCaseName, function () { + Blockly.getFocusManager().getFocusedNode.restore(); + this.comment = setSelectedComment(this.workspace); + this.copySpy = sinon.spy(this.comment, 'toCopyData'); + this.disposeSpy = sinon.spy(this.comment, 'dispose'); + + this.injectionDiv.dispatchEvent(keyEvent); + sinon.assert.calledOnce(this.copySpy); + sinon.assert.calledOnce(this.disposeSpy); + }); + }); + }); }); suite('Undo', function () { From 93a9b6bf2e20a1fa4830f52dff5116e2aceb2167 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 13 Jun 2025 15:08:58 -0700 Subject: [PATCH 13/67] fix: Fix navigation for blocks with multiple statement inputs. (#9143) * fix: Fix navigation for blocks with multiple statement inputs. * chore: Add tests to prevent regressions. --- core/keyboard_nav/block_navigation_policy.ts | 25 +++- tests/mocha/cursor_test.js | 133 +++++++++++++++++++ 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 570b06fe392..2637ad49df5 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -24,7 +24,7 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @returns The first field or input of the given block, if any. */ getFirstChild(current: BlockSvg): IFocusableNode | null { - const candidates = getBlockNavigationCandidates(current); + const candidates = getBlockNavigationCandidates(current, true); return candidates[0]; } @@ -58,6 +58,8 @@ export class BlockNavigationPolicy implements INavigationPolicy { return current.nextConnection?.targetBlock(); } else if (current.outputConnection?.targetBlock()) { return navigateBlock(current, 1); + } else if (current.getSurroundParent()) { + return navigateBlock(current.getTopStackBlock(), 1); } else if (this.getParent(current) instanceof WorkspaceSvg) { return navigateStacks(current, 1); } @@ -111,14 +113,27 @@ export class BlockNavigationPolicy implements INavigationPolicy { * @param block The block to retrieve the navigable children of. * @returns A list of navigable/focusable children of the given block. */ -function getBlockNavigationCandidates(block: BlockSvg): IFocusableNode[] { +function getBlockNavigationCandidates( + block: BlockSvg, + forward: boolean, +): IFocusableNode[] { const candidates: IFocusableNode[] = block.getIcons(); for (const input of block.inputList) { if (!input.isVisible()) continue; candidates.push(...input.fieldRow); if (input.connection?.targetBlock()) { - candidates.push(input.connection.targetBlock() as BlockSvg); + const connectedBlock = input.connection.targetBlock() as BlockSvg; + if (input.connection.type === ConnectionType.NEXT_STATEMENT && !forward) { + const lastStackBlock = connectedBlock + .lastConnectionInStack(false) + ?.getSourceBlock(); + if (lastStackBlock) { + candidates.push(lastStackBlock); + } + } else { + candidates.push(connectedBlock); + } } else if (input.connection?.type === ConnectionType.INPUT_VALUE) { candidates.push(input.connection as RenderedConnection); } @@ -174,11 +189,11 @@ export function navigateBlock( ): IFocusableNode | null { const block = current instanceof BlockSvg - ? current.outputConnection.targetBlock() + ? (current.outputConnection?.targetBlock() ?? current.getSurroundParent()) : current.getSourceBlock(); if (!(block instanceof BlockSvg)) return null; - const candidates = getBlockNavigationCandidates(block); + const candidates = getBlockNavigationCandidates(block, delta > 0); const currentIndex = candidates.indexOf(current); if (currentIndex === -1) return null; diff --git a/tests/mocha/cursor_test.js b/tests/mocha/cursor_test.js index 1d283f331a6..6f841ae09c6 100644 --- a/tests/mocha/cursor_test.js +++ b/tests/mocha/cursor_test.js @@ -60,6 +60,33 @@ suite('Cursor', function () { 'tooltip': '', 'helpUrl': '', }, + { + 'type': 'multi_statement_input', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'FIRST', + }, + { + 'type': 'input_statement', + 'name': 'SECOND', + }, + ], + }, + { + 'type': 'simple_statement', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, ]); this.workspace = Blockly.inject('blocklyDiv', {}); this.cursor = this.workspace.getCursor(); @@ -145,6 +172,112 @@ suite('Cursor', function () { assert.equal(curNode, this.blocks.D.nextConnection); }); }); + + suite('Multiple statement inputs', function () { + setup(function () { + sharedTestSetup.call(this); + Blockly.defineBlocksWithJsonArray([ + { + 'type': 'multi_statement_input', + 'message0': '%1 %2', + 'args0': [ + { + 'type': 'input_statement', + 'name': 'FIRST', + }, + { + 'type': 'input_statement', + 'name': 'SECOND', + }, + ], + }, + { + 'type': 'simple_statement', + 'message0': '%1', + 'args0': [ + { + 'type': 'field_input', + 'name': 'NAME', + 'text': 'default', + }, + ], + 'previousStatement': null, + 'nextStatement': null, + }, + ]); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.cursor = this.workspace.getCursor(); + + this.multiStatement1 = createRenderedBlock( + this.workspace, + 'multi_statement_input', + ); + this.multiStatement2 = createRenderedBlock( + this.workspace, + 'multi_statement_input', + ); + this.firstStatement = createRenderedBlock( + this.workspace, + 'simple_statement', + ); + this.secondStatement = createRenderedBlock( + this.workspace, + 'simple_statement', + ); + this.thirdStatement = createRenderedBlock( + this.workspace, + 'simple_statement', + ); + this.fourthStatement = createRenderedBlock( + this.workspace, + 'simple_statement', + ); + this.multiStatement1 + .getInput('FIRST') + .connection.connect(this.firstStatement.previousConnection); + this.firstStatement.nextConnection.connect( + this.secondStatement.previousConnection, + ); + this.multiStatement1 + .getInput('SECOND') + .connection.connect(this.thirdStatement.previousConnection); + this.multiStatement2 + .getInput('FIRST') + .connection.connect(this.fourthStatement.previousConnection); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('In - from field in nested statement block to next nested statement block', function () { + this.cursor.setCurNode(this.secondStatement.getField('NAME')); + this.cursor.in(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.thirdStatement); + }); + test('In - from field in nested statement block to next stack', function () { + this.cursor.setCurNode(this.thirdStatement.getField('NAME')); + this.cursor.in(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.multiStatement2); + }); + + test('Out - from nested statement block to last field of previous nested statement block', function () { + this.cursor.setCurNode(this.thirdStatement); + this.cursor.out(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.secondStatement.getField('NAME')); + }); + + test('Out - from root block to last field of last nested statement block in previous stack', function () { + this.cursor.setCurNode(this.multiStatement2); + this.cursor.out(); + const curNode = this.cursor.getCurNode(); + assert.equal(curNode, this.thirdStatement.getField('NAME')); + }); + }); + suite('Searching', function () { setup(function () { sharedTestSetup.call(this); From 3e09a70ef4ade66794bb8cab157205faf5088350 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 17:36:27 +0100 Subject: [PATCH 14/67] chore(deps): bump @hyperjump/json-schema from 1.11.0 to 1.15.1 (#9147) Bumps [@hyperjump/json-schema](https://github.com/hyperjump-io/json-schema) from 1.11.0 to 1.15.1. - [Commits](https://github.com/hyperjump-io/json-schema/compare/v1.11.0...v1.15.1) --- updated-dependencies: - dependency-name: "@hyperjump/json-schema" dependency-version: 1.15.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40c20bd0d21..ec6db288c91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -746,9 +746,9 @@ } }, "node_modules/@hyperjump/json-schema": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.11.0.tgz", - "integrity": "sha512-gX1YNObOybUW6tgJjvb1lomNbI/VnY+EBPokmEGy9Lk8cgi+gE0vXhX1XDgIpUUA4UXfgHEn5I1mga5vHgOttg==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.15.1.tgz", + "integrity": "sha512-/NtriODPtJ+4nqewSksw3YtcINXy1C2TraFuhah/IfSdwgBUas0XNCHJz9mXcniR7/2nCUSFMZg9A3wKo3i0iQ==", "dev": true, "license": "MIT", "dependencies": { From f117bbad22b669ad80208d45d7552ed2017bf3dd Mon Sep 17 00:00:00 2001 From: RoboErikG Date: Mon, 16 Jun 2025 12:35:10 -0700 Subject: [PATCH 15/67] Simplify check for existence of isCopyable Co-authored-by: Christopher Allen --- core/shortcut_items.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index dca2d73668f..25295e417c0 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -108,7 +108,7 @@ let copyCoords: Coordinate | null = null; function isCopyable(focused: IFocusableNode): boolean { if (!isICopyable(focused) || !isIDeletable(focused) || !isDraggable(focused)) return false; - if (focused.isCopyable !== undefined) { + if (focused.isCopyable) { return focused.isCopyable(); } else if ( focused instanceof BlockSvg || From 2bae8eb377f881abb061e9a45f563a1800725ef2 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Mon, 16 Jun 2025 12:38:46 -0700 Subject: [PATCH 16/67] Update isCopyable comment --- core/interfaces/i_copyable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/interfaces/i_copyable.ts b/core/interfaces/i_copyable.ts index 246dd4dd509..6c354926a64 100644 --- a/core/interfaces/i_copyable.ts +++ b/core/interfaces/i_copyable.ts @@ -17,7 +17,8 @@ export interface ICopyable extends ISelectable { toCopyData(): T | null; /** - * Whether this instance is currently copyable. + * Whether this instance is currently copyable. The standard implementation + * is to return true if isOwnDeletable and isOwnMovable return true. * * @returns True if it can currently be copied. */ From afe53c5194e13fc4356b240d9ff0652e74f7ed7c Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 16 Jun 2025 15:45:01 -0700 Subject: [PATCH 17/67] fix: Dispatch keyboard events with the workspace they occurred on. (#9137) * fix: Dispatch keyboard events with the workspace they occurred on. * chore: Add comment warding off would-be refactorers. --- core/common.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/core/common.ts b/core/common.ts index a4b198ae490..7f23779ec93 100644 --- a/core/common.ts +++ b/core/common.ts @@ -320,21 +320,28 @@ export function defineBlocks(blocks: {[key: string]: BlockDefinition}) { * @param e Key down event. */ export function globalShortcutHandler(e: KeyboardEvent) { - const mainWorkspace = getMainWorkspace() as WorkspaceSvg; - if (!mainWorkspace) { - return; + // This would ideally just be a `focusedTree instanceof WorkspaceSvg`, but + // importing `WorkspaceSvg` (as opposed to just its type) causes cycles. + let workspace: WorkspaceSvg = getMainWorkspace() as WorkspaceSvg; + const focusedTree = getFocusManager().getFocusedTree(); + for (const ws of getAllWorkspaces()) { + if (focusedTree === (ws as WorkspaceSvg)) { + workspace = ws as WorkspaceSvg; + break; + } } if ( browserEvents.isTargetInput(e) || - (mainWorkspace.rendered && !mainWorkspace.isVisible()) + !workspace || + (workspace.rendered && !workspace.isFlyout && !workspace.isVisible()) ) { // When focused on an HTML text input widget, don't trap any keys. // Ignore keypresses on rendered workspaces that have been explicitly // hidden. return; } - ShortcutRegistry.registry.onKeyDown(mainWorkspace, e); + ShortcutRegistry.registry.onKeyDown(workspace, e); } export const TEST_ONLY = {defineBlocksWithJsonArrayInternal}; From cf3fcccec1c717ac11e5850185c864007eb4b536 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Wed, 18 Jun 2025 11:15:41 -0700 Subject: [PATCH 18/67] fix: caret position when editing block comments (#9153) --- core/bubbles/textinput_bubble.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts index 6281ad7584e..4946ee458b1 100644 --- a/core/bubbles/textinput_bubble.ts +++ b/core/bubbles/textinput_bubble.ts @@ -173,6 +173,10 @@ export class TextInputBubble extends Bubble { browserEvents.conditionalBind(textArea, 'wheel', this, (e: Event) => { e.stopPropagation(); }); + // Don't let the pointerdown event get to the workspace. + browserEvents.conditionalBind(textArea, 'pointerdown', this, (e: Event) => { + e.stopPropagation(); + }); browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange); } From 97ffea73becea41465e7c4e757fab44a2ded10df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 12:29:03 -0700 Subject: [PATCH 19/67] chore(deps): bump @hyperjump/browser from 1.1.6 to 1.3.1 (#9148) Bumps [@hyperjump/browser](https://github.com/hyperjump-io/browser) from 1.1.6 to 1.3.1. - [Commits](https://github.com/hyperjump-io/browser/compare/v1.1.6...v1.3.1) --- updated-dependencies: - dependency-name: "@hyperjump/browser" dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec6db288c91..20311ab05f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -717,10 +717,11 @@ } }, "node_modules/@hyperjump/browser": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.1.6.tgz", - "integrity": "sha512-i27uPV7SxK1GOn7TLTRxTorxchYa5ur9JHgtl6TxZ1MHuyb9ROAnXxEeu4q4H1836Xb7lL2PGPsaa5Jl3p+R6g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.3.1.tgz", + "integrity": "sha512-Le5XZUjnVqVjkgLYv6yyWgALat/0HpB1XaCPuCZ+GCFki9NvXloSZITIJ0H+wRW7mb9At1SxvohKBbNQbrr/cw==", "dev": true, + "license": "MIT", "dependencies": { "@hyperjump/json-pointer": "^1.1.0", "@hyperjump/uri": "^1.2.0", From acdb27ee67f2fcc36598c95aa3a9895e904c9f55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 11:25:33 +0000 Subject: [PATCH 20/67] chore(deps): bump globals from 16.1.0 to 16.2.0 Bumps [globals](https://github.com/sindresorhus/globals) from 16.1.0 to 16.2.0. - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v16.1.0...v16.2.0) --- updated-dependencies: - dependency-name: globals dependency-version: 16.2.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 20311ab05f0..d014f6240ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5464,9 +5464,9 @@ } }, "node_modules/globals": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", - "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", + "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", "dev": true, "license": "MIT", "engines": { From 1e5b4e9f422ed7fe9eebc04d5fe9921252a3b299 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 23 Jun 2025 09:09:56 -0700 Subject: [PATCH 21/67] feat: Add support for keyboard navigation into mutator workspaces. (#9151) * feat: Add support for keyboard navigation into mutators. * fix: Prevent mutator bubbles from jumping wildly during keyboard nav. --- core/bubbles/mini_workspace_bubble.ts | 16 +++++++++++++-- core/icons/mutator_icon.ts | 3 +-- core/keyboard_nav/icon_navigation_policy.ts | 14 +++++++++++-- .../workspace_navigation_policy.ts | 2 +- core/navigator.ts | 3 +-- core/workspace_svg.ts | 20 +++++++++++++++++-- 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/core/bubbles/mini_workspace_bubble.ts b/core/bubbles/mini_workspace_bubble.ts index f6ea609361b..194cb41f35d 100644 --- a/core/bubbles/mini_workspace_bubble.ts +++ b/core/bubbles/mini_workspace_bubble.ts @@ -153,7 +153,11 @@ export class MiniWorkspaceBubble extends Bubble { * are dealt with by resizing the workspace to show them. */ private bumpBlocksIntoBounds() { - if (this.miniWorkspace.isDragging()) return; + if ( + this.miniWorkspace.isDragging() && + !this.miniWorkspace.keyboardMoveInProgress + ) + return; const MARGIN = 20; @@ -185,7 +189,15 @@ export class MiniWorkspaceBubble extends Bubble { * mini workspace. */ private updateBubbleSize() { - if (this.miniWorkspace.isDragging()) return; + if ( + this.miniWorkspace.isDragging() && + !this.miniWorkspace.keyboardMoveInProgress + ) + return; + + // Disable autolayout if a keyboard move is in progress to prevent the + // mutator bubble from jumping around. + this.autoLayout &&= !this.miniWorkspace.keyboardMoveInProgress; const currSize = this.getSize(); const newSize = this.calculateWorkspaceSize(); diff --git a/core/icons/mutator_icon.ts b/core/icons/mutator_icon.ts index 1842855fab5..9055a91ea8f 100644 --- a/core/icons/mutator_icon.ts +++ b/core/icons/mutator_icon.ts @@ -14,7 +14,6 @@ import {BlockChange} from '../events/events_block_change.js'; import {isBlockChange, isBlockCreate} from '../events/predicates.js'; import {EventType} from '../events/type.js'; import * as eventUtils from '../events/utils.js'; -import type {IBubble} from '../interfaces/i_bubble.js'; import type {IHasBubble} from '../interfaces/i_has_bubble.js'; import * as renderManagement from '../render_management.js'; import {Coordinate} from '../utils/coordinate.js'; @@ -205,7 +204,7 @@ export class MutatorIcon extends Icon implements IHasBubble { } /** See IHasBubble.getBubble. */ - getBubble(): IBubble | null { + getBubble(): MiniWorkspaceBubble | null { return this.miniWorkspaceBubble; } diff --git a/core/keyboard_nav/icon_navigation_policy.ts b/core/keyboard_nav/icon_navigation_policy.ts index 96908cbbdf8..70631ce81af 100644 --- a/core/keyboard_nav/icon_navigation_policy.ts +++ b/core/keyboard_nav/icon_navigation_policy.ts @@ -5,7 +5,9 @@ */ import {BlockSvg} from '../block_svg.js'; +import {getFocusManager} from '../focus_manager.js'; import {Icon} from '../icons/icon.js'; +import {MutatorIcon} from '../icons/mutator_icon.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; import {navigateBlock} from './block_navigation_policy.js'; @@ -17,10 +19,18 @@ export class IconNavigationPolicy implements INavigationPolicy { /** * Returns the first child of the given icon. * - * @param _current The icon to return the first child of. + * @param current The icon to return the first child of. * @returns Null. */ - getFirstChild(_current: Icon): IFocusableNode | null { + getFirstChild(current: Icon): IFocusableNode | null { + if ( + current instanceof MutatorIcon && + current.bubbleIsVisible() && + getFocusManager().getFocusedNode() === current + ) { + return current.getBubble()?.getWorkspace() ?? null; + } + return null; } diff --git a/core/keyboard_nav/workspace_navigation_policy.ts b/core/keyboard_nav/workspace_navigation_policy.ts index 12a7555b43f..b671f8fe739 100644 --- a/core/keyboard_nav/workspace_navigation_policy.ts +++ b/core/keyboard_nav/workspace_navigation_policy.ts @@ -62,7 +62,7 @@ export class WorkspaceNavigationPolicy * @returns True if the given workspace can be focused. */ isNavigable(current: WorkspaceSvg): boolean { - return current.canBeFocused(); + return current.canBeFocused() && !current.isMutator; } /** diff --git a/core/navigator.ts b/core/navigator.ts index 92c921122dc..77bb64cd8c7 100644 --- a/core/navigator.ts +++ b/core/navigator.ts @@ -64,9 +64,8 @@ export class Navigator { getFirstChild(current: IFocusableNode): IFocusableNode | null { const result = this.get(current)?.getFirstChild(current); if (!result) return null; - // If the child isn't navigable, don't traverse into it; check its peers. if (!this.get(result)?.isNavigable(result)) { - return this.getNextSibling(result); + return this.getFirstChild(result) || this.getNextSibling(result); } return result; } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 9eb5ea545b8..552d3706184 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -41,6 +41,7 @@ import type {FlyoutButton} from './flyout_button.js'; import {getFocusManager} from './focus_manager.js'; import {Gesture} from './gesture.js'; import {Grid} from './grid.js'; +import {MutatorIcon} from './icons/mutator_icon.js'; import {isAutoHideable} from './interfaces/i_autohideable.js'; import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import {IContextMenu} from './interfaces/i_contextmenu.js'; @@ -2680,7 +2681,7 @@ export class WorkspaceSvg /** See IFocusableNode.getFocusableTree. */ getFocusableTree(): IFocusableTree { - return this; + return (this.isMutator && this.options.parentWorkspace) || this; } /** See IFocusableNode.onNodeFocus. */ @@ -2710,7 +2711,22 @@ export class WorkspaceSvg /** See IFocusableTree.getNestedTrees. */ getNestedTrees(): Array { - return []; + const nestedWorkspaces = this.getAllBlocks() + .map((block) => block.getIcons()) + .flat() + .filter( + (icon): icon is MutatorIcon => + icon instanceof MutatorIcon && icon.bubbleIsVisible(), + ) + .map((icon) => icon.getBubble()?.getWorkspace()) + .filter((workspace) => !!workspace); + + const ownFlyout = this.getFlyout(true); + if (ownFlyout) { + nestedWorkspaces.push(ownFlyout.getWorkspace()); + } + + return nestedWorkspaces; } /** See IFocusableTree.lookUpFocusableNode. */ From 253ea15ab491a63703df023b1da709efd5cd0de7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 09:10:52 -0700 Subject: [PATCH 22/67] chore(deps): bump eslint-plugin-prettier from 5.4.0 to 5.5.0 (#9157) Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 5.4.0 to 5.5.0. - [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases) - [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.4.0...v5.5.0) --- updated-dependencies: - dependency-name: eslint-plugin-prettier dependency-version: 5.5.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 74 +++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 20311ab05f0..47f36c5ab55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1215,6 +1215,19 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz", + "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@promptbook/utils": { "version": "0.69.5", "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz", @@ -4337,14 +4350,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.0.tgz", - "integrity": "sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.0.tgz", + "integrity": "sha512-8qsOYwkkGrahrgoUv76NZi23koqXOGiiEzXMrT8Q7VcYaUISR+5MorIUxfWqYXN0fN/31WbSrxCxFkVQ43wwrA==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.0" + "synckit": "^0.11.7" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -4367,43 +4380,6 @@ } } }, - "node_modules/eslint-plugin-prettier/node_modules/@pkgr/core": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.4.tgz", - "integrity": "sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/eslint-plugin-prettier/node_modules/synckit": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.4.tgz", - "integrity": "sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.3", - "tslib": "^2.8.1" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/eslint-plugin-prettier/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, "node_modules/eslint-scope": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", @@ -9552,6 +9528,22 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, + "node_modules/synckit": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz", + "integrity": "sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.4" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tar-fs": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.9.tgz", From 21216e85d3c70cf60955c578d57dc2d715009598 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:11:39 +0000 Subject: [PATCH 23/67] chore(deps): bump prettier from 3.3.3 to 3.6.0 Bumps [prettier](https://github.com/prettier/prettier) from 3.3.3 to 3.6.0. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.3.3...3.6.0) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 47f36c5ab55..7aa9dac8e00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8223,9 +8223,9 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.0.tgz", + "integrity": "sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==", "dev": true, "license": "MIT", "bin": { From 28d6ff7da56180207679cfbdf9a95b2db6eb55ab Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 23 Jun 2025 09:14:39 -0700 Subject: [PATCH 24/67] chore: Update messages for keyboard-experiment. (#9152) * chore: Remove unused messages. * fix: Remove unneeded message placeholders. * feat: Add additional messages used in the keyboard experiment. * chore: Update messages. --- msg/json/en.json | 32 +++++++------------ msg/json/qqq.json | 20 +++--------- msg/messages.js | 78 +++++++++++------------------------------------ 3 files changed, 34 insertions(+), 96 deletions(-) diff --git a/msg/json/en.json b/msg/json/en.json index e7c468d288a..5494d7fb09f 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2025-04-21 10:42:10.549634", + "lastupdated": "2025-06-17 15:36:41.845826", "locale": "en", "messagedocumentation" : "qqq" }, @@ -398,22 +398,8 @@ "COLLAPSED_WARNINGS_WARNING": "Collapsed blocks contain warnings.", "DIALOG_OK": "OK", "DIALOG_CANCEL": "Cancel", - "DELETE_SHORTCUT": "Delete block (%1)", - "DELETE_KEY": "Del", - "EDIT_BLOCK_CONTENTS": "Edit Block contents (%1)", - "INSERT_BLOCK": "Insert Block (%1)", - "START_MOVE": "Start move", - "FINISH_MOVE": "Finish move", - "ABORT_MOVE": "Abort move", - "MOVE_LEFT_CONSTRAINED": "Move left, constrained", - "MOVE_RIGHT_CONSTRAINED": "Move right constrained", - "MOVE_UP_CONSTRAINED": "Move up, constrained", - "MOVE_DOWN_CONSTRAINED": "Move down constrained", - "MOVE_LEFT_UNCONSTRAINED": "Move left, unconstrained", - "MOVE_RIGHT_UNCONSTRAINED": "Move right, unconstrained", - "MOVE_UP_UNCONSTRAINED": "Move up unconstrained", - "MOVE_DOWN_UNCONSTRAINED": "Move down, unconstrained", - "MOVE_BLOCK": "Move Block (%1)", + "EDIT_BLOCK_CONTENTS": "Edit Block contents", + "MOVE_BLOCK": "Move Block", "WINDOWS": "Windows", "MAC_OS": "macOS", "CHROME_OS": "ChromeOS", @@ -423,11 +409,15 @@ "COMMAND_KEY": "⌘ Command", "OPTION_KEY": "⌥ Option", "ALT_KEY": "Alt", - "CUT_SHORTCUT": "Cut (%1)", - "COPY_SHORTCUT": "Copy (%1)", - "PASTE_SHORTCUT": "Paste (%1)", + "CUT_SHORTCUT": "Cut", + "COPY_SHORTCUT": "Copy", + "PASTE_SHORTCUT": "Paste", "HELP_PROMPT": "Press %1 for help on keyboard controls", "SHORTCUTS_GENERAL": "General", "SHORTCUTS_EDITING": "Editing", - "SHORTCUTS_CODE_NAVIGATION": "Code navigation" + "SHORTCUTS_CODE_NAVIGATION": "Code navigation", + "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Hold %1 and use arrow keys to move freely, then %2 to accept the position", + "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Use the arrow keys to move, then %1 to accept the position", + "KEYBOARD_NAV_COPIED_HINT": "Copied. Press %1 to paste.", + "KEYBOARD_NAV_CUT_HINT": "Cut. Press %1 to paste." } diff --git a/msg/json/qqq.json b/msg/json/qqq.json index 5436da59f9d..5e03efc4153 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -405,21 +405,7 @@ "COLLAPSED_WARNINGS_WARNING": "warning - This appears if the user collapses a block, and blocks inside that block have warnings attached to them. It should inform the user that the block they collapsed contains blocks that have warnings.", "DIALOG_OK": "button label - Pressing this button closes help information.\n{{Identical|OK}}", "DIALOG_CANCEL": "button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}}", - "DELETE_SHORTCUT": "menu label - Contextual menu item that deletes the focused block.", - "DELETE_KEY": "menu label - Keyboard shortcut for the Delete key, shown at the end of a menu item that deletes the focused block.", "EDIT_BLOCK_CONTENTS": "menu label - Contextual menu item that moves the keyboard navigation cursor into a subitem of the focused block.", - "INSERT_BLOCK": "menu label - Contextual menu item that prompts the user to choose a block to insert into the program at the focused location.", - "START_MOVE": "keyboard shortcut label - Contextual menu item that starts a keyboard-driven move of the focused block.", - "FINISH_MOVE": "keyboard shortcut label - Contextual menu item that ends a keyboard-driven move of the focused block.", - "ABORT_MOVE": "keyboard shortcut label - Contextual menu item that ends a keyboard-drive move of the focused block by returning it to its original location.", - "MOVE_LEFT_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location to the left.", - "MOVE_RIGHT_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location to the right.", - "MOVE_UP_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location above it.", - "MOVE_DOWN_CONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block to the next valid location below it.", - "MOVE_LEFT_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely to the left.", - "MOVE_RIGHT_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely to the right.", - "MOVE_UP_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely upwards.", - "MOVE_DOWN_UNCONSTRAINED": "keyboard shortcut label - Description of shortcut that moves a block freely downwards.", "MOVE_BLOCK": "menu label - Contextual menu item that starts a keyboard-driven block move.", "WINDOWS": "Name of the Microsoft Windows operating system displayed in a list of keyboard shortcuts.", "MAC_OS": "Name of the Apple macOS operating system displayed in a list of keyboard shortcuts,", @@ -436,5 +422,9 @@ "HELP_PROMPT": "Alert message shown to prompt users to review available keyboard shortcuts.", "SHORTCUTS_GENERAL": "shortcut list section header - Label for general purpose keyboard shortcuts.", "SHORTCUTS_EDITING": "shortcut list section header - Label for keyboard shortcuts related to editing a workspace.", - "SHORTCUTS_CODE_NAVIGATION": "shortcut list section header - Label for keyboard shortcuts related to moving around the workspace." + "SHORTCUTS_CODE_NAVIGATION": "shortcut list section header - Label for keyboard shortcuts related to moving around the workspace.", + "KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks to arbitrary locations with the keyboard.", + "KEYBOARD_NAV_CONSTRAINED_MOVE_HINT": "Message shown to inform users how to move blocks with the keyboard.", + "KEYBOARD_NAV_COPIED_HINT": "Message shown when an item is copied in keyboard navigation mode.", + "KEYBOARD_NAV_CUT_HINT": "Message shown when an item is cut in keyboard navigation mode." } diff --git a/msg/messages.js b/msg/messages.js index d0c3e17688a..b7611b4849b 100644 --- a/msg/messages.js +++ b/msg/messages.js @@ -1618,68 +1618,13 @@ Blockly.Msg.DIALOG_OK = 'OK'; /// button label - Pressing this button cancels a proposed action.\n{{Identical|Cancel}} Blockly.Msg.DIALOG_CANCEL = 'Cancel'; -/** @type {string} */ -/// menu label - Contextual menu item that deletes the focused block. -Blockly.Msg.DELETE_SHORTCUT = 'Delete block (%1)'; -/** @type {string} */ -/// menu label - Keyboard shortcut for the Delete key, shown at the end of a -/// menu item that deletes the focused block. -Blockly.Msg.DELETE_KEY = 'Del'; /** @type {string} */ /// menu label - Contextual menu item that moves the keyboard navigation cursor /// into a subitem of the focused block. -Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents (%1)'; -/** @type {string} */ -/// menu label - Contextual menu item that prompts the user to choose a block to -/// insert into the program at the focused location. -Blockly.Msg.INSERT_BLOCK = 'Insert Block (%1)'; -/** @type {string} */ -/// keyboard shortcut label - Contextual menu item that starts a keyboard-driven -/// move of the focused block. -Blockly.Msg.START_MOVE = 'Start move'; -/** @type {string} */ -/// keyboard shortcut label - Contextual menu item that ends a keyboard-driven -/// move of the focused block. -Blockly.Msg.FINISH_MOVE = 'Finish move'; -/** @type {string} */ -/// keyboard shortcut label - Contextual menu item that ends a keyboard-drive -/// move of the focused block by returning it to its original location. -Blockly.Msg.ABORT_MOVE = 'Abort move'; -/** @type {string} */ -/// keyboard shortcut label - Description of shortcut that moves a block to the -/// next valid location to the left. -Blockly.Msg.MOVE_LEFT_CONSTRAINED = 'Move left, constrained'; -/** @type {string} */ -/// keyboard shortcut label - Description of shortcut that moves a block to the -/// next valid location to the right. -Blockly.Msg.MOVE_RIGHT_CONSTRAINED = 'Move right constrained'; -/** @type {string} */ -/// keyboard shortcut label - Description of shortcut that moves a block to the -/// next valid location above it. -Blockly.Msg.MOVE_UP_CONSTRAINED = 'Move up, constrained'; -/** @type {string} */ -/// keyboard shortcut label - Description of shortcut that moves a block to the -/// next valid location below it. -Blockly.Msg.MOVE_DOWN_CONSTRAINED = 'Move down constrained'; -/** @type {string} */ -/// keyboard shortcut label - Description of shortcut that moves a block freely -/// to the left. -Blockly.Msg.MOVE_LEFT_UNCONSTRAINED = 'Move left, unconstrained'; -/** @type {string} */ -/// keyboard shortcut label - Description of shortcut that moves a block freely -/// to the right. -Blockly.Msg.MOVE_RIGHT_UNCONSTRAINED = 'Move right, unconstrained'; -/** @type {string} */ -/// keyboard shortcut label - Description of shortcut that moves a block freely -/// upwards. -Blockly.Msg.MOVE_UP_UNCONSTRAINED = 'Move up unconstrained'; -/** @type {string} */ -/// keyboard shortcut label - Description of shortcut that moves a block freely -/// downwards. -Blockly.Msg.MOVE_DOWN_UNCONSTRAINED = 'Move down, unconstrained'; +Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents'; /** @type {string} */ /// menu label - Contextual menu item that starts a keyboard-driven block move. -Blockly.Msg.MOVE_BLOCK = 'Move Block (%1)'; +Blockly.Msg.MOVE_BLOCK = 'Move Block'; /** @type {string} */ /// Name of the Microsoft Windows operating system displayed in a list of /// keyboard shortcuts. @@ -1714,13 +1659,13 @@ Blockly.Msg.OPTION_KEY = '⌥ Option'; Blockly.Msg.ALT_KEY = 'Alt'; /** @type {string} */ /// menu label - Contextual menu item that cuts the focused item. -Blockly.Msg.CUT_SHORTCUT = 'Cut (%1)'; +Blockly.Msg.CUT_SHORTCUT = 'Cut'; /** @type {string} */ /// menu label - Contextual menu item that copies the focused item. -Blockly.Msg.COPY_SHORTCUT = 'Copy (%1)'; +Blockly.Msg.COPY_SHORTCUT = 'Copy'; /** @type {string} */ /// menu label - Contextual menu item that pastes the previously copied item. -Blockly.Msg.PASTE_SHORTCUT = 'Paste (%1)'; +Blockly.Msg.PASTE_SHORTCUT = 'Paste'; /** @type {string} */ /// Alert message shown to prompt users to review available keyboard shortcuts. Blockly.Msg.HELP_PROMPT = 'Press %1 for help on keyboard controls'; @@ -1735,3 +1680,16 @@ Blockly.Msg.SHORTCUTS_EDITING = 'Editing' /// shortcut list section header - Label for keyboard shortcuts related to /// moving around the workspace. Blockly.Msg.SHORTCUTS_CODE_NAVIGATION = 'Code navigation'; +/** @type {string} */ +/// Message shown to inform users how to move blocks to arbitrary locations +/// with the keyboard. +Blockly.Msg.KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT = 'Hold %1 and use arrow keys to move freely, then %2 to accept the position'; +/** @type {string} */ +/// Message shown to inform users how to move blocks with the keyboard. +Blockly.Msg.KEYBOARD_NAV_CONSTRAINED_MOVE_HINT = 'Use the arrow keys to move, then %1 to accept the position'; +/** @type {string} */ +/// Message shown when an item is copied in keyboard navigation mode. +Blockly.Msg.KEYBOARD_NAV_COPIED_HINT = 'Copied. Press %1 to paste.'; +/** @type {string} */ +/// Message shown when an item is cut in keyboard navigation mode. +Blockly.Msg.KEYBOARD_NAV_CUT_HINT = 'Cut. Press %1 to paste.'; \ No newline at end of file From af4a4b4100b9d26971fb4ed0508ff0d8508b8a99 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 23 Jun 2025 11:50:02 -0700 Subject: [PATCH 25/67] feat: Run keyboard plugin tests in CI (#9135) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves N/A (no tracking issue) ### Proposed Changes Introduces a GitHub actions CI workflow to run the webdriver IO tests from https://github.com/google/blockly-keyboard-experimentation as part of core Blockly's CI. ### Reason for Changes Since development on the plugin is going to continue for many months yet, this ensures that behavioral changes in core Blockly don't inadvertently break the plugin. Note that this shouldn't be made a blocking workflow since there may be cases where it's necessary to break the plugin before a change to the plugin itself can be introduced to then fix it (as this has happened many times in the past). However, the CI check is forced signal to both author and reviewer as to whether their change affects the plugin without having to manually check the test suite. ### Test Coverage N/A -- Verifying that the CI workflow runs is sufficient. ### Documentation No documentation changes are needed here. ### Additional Information Nothing. --- .github/workflows/keyboard_plugin_test.yml | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/keyboard_plugin_test.yml diff --git a/.github/workflows/keyboard_plugin_test.yml b/.github/workflows/keyboard_plugin_test.yml new file mode 100644 index 00000000000..753d31dda1e --- /dev/null +++ b/.github/workflows/keyboard_plugin_test.yml @@ -0,0 +1,66 @@ +# Workflow for running the keyboard navigation plugin's automated tests. + +name: Keyboard Navigation Automated Tests + +on: + workflow_dispatch: + pull_request: + push: + branches: + - develop + +permissions: + contents: read + +jobs: + webdriverio_tests: + name: WebdriverIO tests + timeout-minutes: 10 + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + steps: + - name: Checkout core Blockly + uses: actions/checkout@v4 + with: + path: core-blockly + + - name: Checkout keyboard navigation plugin + uses: actions/checkout@v4 + with: + repository: 'google/blockly-keyboard-experimentation' + ref: 'main' + path: blockly-keyboard-experimentation + + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + + - name: NPM install + run: | + cd core-blockly + npm install + cd .. + cd blockly-keyboard-experimentation + npm install + cd .. + + - name: Link latest core develop with plugin + run: | + cd core-blockly + npm run package + cd dist + npm link + cd ../../blockly-keyboard-experimentation + npm link blockly + cd .. + + - name: Run keyboard navigation plugin tests + run: | + cd blockly-keyboard-experimentation + npm run test From 5427c3df335b3a1ff54d209e37f3db888745f44d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:03:23 -0700 Subject: [PATCH 26/67] chore(deps): bump mocha from 11.3.0 to 11.7.0 (#9159) Bumps [mocha](https://github.com/mochajs/mocha) from 11.3.0 to 11.7.0. - [Release notes](https://github.com/mochajs/mocha/releases) - [Changelog](https://github.com/mochajs/mocha/blob/main/CHANGELOG.md) - [Commits](https://github.com/mochajs/mocha/compare/v11.3.0...v11.7.0) --- updated-dependencies: - dependency-name: mocha dependency-version: 11.7.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 55 +++++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3810c4ef4e3..8f8de5349ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3841,10 +3841,11 @@ } }, "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -7263,29 +7264,29 @@ } }, "node_modules/mocha": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.3.0.tgz", - "integrity": "sha512-J0RLIM89xi8y6l77bgbX+03PeBRDQCOVQpnwOcCN7b8hCmbh6JvGI2ZDJ5WMoHz+IaPU+S4lvTd0j51GmBAdgQ==", + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.0.tgz", + "integrity": "sha512-bXfLy/mI8n4QICg+pWj1G8VduX5vC0SHRwFpiR5/Fxc8S2G906pSfkyMmHVsdJNQJQNh3LE67koad9GzEvkV6g==", "dev": true, "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", "debug": "^4.3.5", - "diff": "^5.2.0", + "diff": "^7.0.0", "escape-string-regexp": "^4.0.0", "find-up": "^5.0.0", "glob": "^10.4.5", "he": "^1.2.0", "js-yaml": "^4.1.0", "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", + "minimatch": "^9.0.5", "ms": "^2.1.3", "picocolors": "^1.1.1", "serialize-javascript": "^6.0.2", "strip-json-comments": "^3.1.1", "supports-color": "^8.1.1", - "workerpool": "^6.5.1", + "workerpool": "^9.2.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1", "yargs-unparser": "^2.0.0" @@ -7345,22 +7346,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mocha/node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -7385,16 +7370,19 @@ "license": "ISC" }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mocha/node_modules/path-scurry": { @@ -10314,10 +10302,11 @@ } }, "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.2.tgz", + "integrity": "sha512-Xz4Nm9c+LiBHhDR5bDLnNzmj6+5F+cyEAWPMkbs2awq/dYazR/efelZzUAjB/y3kNHL+uzkHvxVVpaOfGCPV7A==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/wrap-ansi": { "version": "7.0.0", From eaf5eea98ec7722c3bac442ef9e840e817de3aa2 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 24 Jun 2025 12:40:23 -0700 Subject: [PATCH 27/67] feat: make comment editor separately focusable from comment itself (#9154) * feat: make comment editor separately focusable from comment itself * feat: improve design and add styling * chore: fix lint * fix: add event listeners to focus parent comment * fix: export CommentEditor * fix: export CommentEditor * fix: extract comment identifier to constant --- core/comments.ts | 1 + core/comments/comment_editor.ts | 188 ++++++++++++++++++++ core/comments/comment_view.ts | 133 +++++--------- core/comments/rendered_workspace_comment.ts | 19 +- core/workspace_svg.ts | 45 ++++- 5 files changed, 284 insertions(+), 102 deletions(-) create mode 100644 core/comments/comment_editor.ts diff --git a/core/comments.ts b/core/comments.ts index ee85919873a..86e8f50b95c 100644 --- a/core/comments.ts +++ b/core/comments.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export {CommentEditor} from './comments/comment_editor.js'; export {CommentView} from './comments/comment_view.js'; export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; export {WorkspaceComment} from './comments/workspace_comment.js'; diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts new file mode 100644 index 00000000000..f921168fa13 --- /dev/null +++ b/core/comments/comment_editor.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import {getFocusManager} from '../focus_manager.js'; +import {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import * as dom from '../utils/dom.js'; +import {Size} from '../utils/size.js'; +import {Svg} from '../utils/svg.js'; +import {WorkspaceSvg} from '../workspace_svg.js'; + +/** + * String added to the ID of a workspace comment to identify + * the focusable node for the comment editor. + */ +export const COMMENT_EDITOR_FOCUS_IDENTIFIER = '_comment_textarea_'; + +/** The part of a comment that can be typed into. */ +export class CommentEditor implements IFocusableNode { + id?: string; + /** The foreignObject containing the HTML text area. */ + private foreignObject: SVGForeignObjectElement; + + /** The text area where the user can type. */ + private textArea: HTMLTextAreaElement; + + /** Listeners for changes to text. */ + private textChangeListeners: Array< + (oldText: string, newText: string) => void + > = []; + + /** The current text of the comment. Updates on text area change. */ + private text: string = ''; + + constructor( + public workspace: WorkspaceSvg, + commentId?: string, + private onFinishEditing?: () => void, + ) { + this.foreignObject = dom.createSvgElement(Svg.FOREIGNOBJECT, { + 'class': 'blocklyCommentForeignObject', + }); + const body = document.createElementNS(dom.HTML_NS, 'body'); + body.setAttribute('xmlns', dom.HTML_NS); + body.className = 'blocklyMinimalBody'; + this.textArea = document.createElementNS( + dom.HTML_NS, + 'textarea', + ) as HTMLTextAreaElement; + dom.addClass(this.textArea, 'blocklyCommentText'); + dom.addClass(this.textArea, 'blocklyTextarea'); + dom.addClass(this.textArea, 'blocklyText'); + body.appendChild(this.textArea); + this.foreignObject.appendChild(body); + + if (commentId) { + this.id = commentId + COMMENT_EDITOR_FOCUS_IDENTIFIER; + this.textArea.setAttribute('id', this.id); + } + + // Register browser event listeners for the user typing in the textarea. + browserEvents.conditionalBind( + this.textArea, + 'change', + this, + this.onTextChange, + ); + + // Register listener for pointerdown to focus the textarea. + browserEvents.conditionalBind( + this.textArea, + 'pointerdown', + this, + (e: PointerEvent) => { + // don't allow this event to bubble up + // and steal focus away from the editor/comment. + e.stopPropagation(); + getFocusManager().focusNode(this); + }, + ); + + // Register listener for keydown events that would finish editing. + browserEvents.conditionalBind( + this.textArea, + 'keydown', + this, + this.handleKeyDown, + ); + } + + /** Gets the dom structure for this comment editor. */ + getDom(): SVGForeignObjectElement { + return this.foreignObject; + } + + /** Gets the current text of the comment. */ + getText(): string { + return this.text; + } + + /** Sets the current text of the comment and fires change listeners. */ + setText(text: string) { + this.textArea.value = text; + this.onTextChange(); + } + + /** + * Triggers listeners when the text of the comment changes, either + * programmatically or manually by the user. + */ + private onTextChange() { + const oldText = this.text; + this.text = this.textArea.value; + // Loop through listeners backwards in case they remove themselves. + for (let i = this.textChangeListeners.length - 1; i >= 0; i--) { + this.textChangeListeners[i](oldText, this.text); + } + } + + /** + * Do something when the user indicates they've finished editing. + * + * @param e Keyboard event. + */ + private handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' || (e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { + if (this.onFinishEditing) this.onFinishEditing(); + e.stopPropagation(); + } + } + + /** Registers a callback that listens for text changes. */ + addTextChangeListener(listener: (oldText: string, newText: string) => void) { + this.textChangeListeners.push(listener); + } + + /** Removes the given listener from the list of text change listeners. */ + removeTextChangeListener(listener: () => void) { + this.textChangeListeners.splice( + this.textChangeListeners.indexOf(listener), + 1, + ); + } + + /** Sets the placeholder text displayed for an empty comment. */ + setPlaceholderText(text: string) { + this.textArea.placeholder = text; + } + + /** Sets whether the textarea is editable. If not, the textarea will be readonly. */ + setEditable(isEditable: boolean) { + if (isEditable) { + this.textArea.removeAttribute('readonly'); + } else { + this.textArea.setAttribute('readonly', 'true'); + } + } + + /** Update the size of the comment editor element. */ + updateSize(size: Size, topBarSize: Size) { + this.foreignObject.setAttribute( + 'height', + `${size.height - topBarSize.height}`, + ); + this.foreignObject.setAttribute('width', `${size.width}`); + this.foreignObject.setAttribute('y', `${topBarSize.height}`); + if (this.workspace.RTL) { + this.foreignObject.setAttribute('x', `${-size.width}`); + } + } + + getFocusableElement(): HTMLElement | SVGElement { + return this.textArea; + } + getFocusableTree(): IFocusableTree { + return this.workspace; + } + onNodeFocus(): void {} + onNodeBlur(): void {} + canBeFocused(): boolean { + if (this.id) return true; + return false; + } +} diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 26623d40f74..1e5ad4a52c2 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -6,6 +6,7 @@ import * as browserEvents from '../browser_events.js'; import * as css from '../css.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node'; import {IRenderedElement} from '../interfaces/i_rendered_element.js'; import * as layers from '../layers.js'; import * as touch from '../touch.js'; @@ -15,6 +16,7 @@ import * as drag from '../utils/drag.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; import {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentEditor} from './comment_editor.js'; export class CommentView implements IRenderedElement { /** The root group element of the comment view. */ @@ -46,11 +48,8 @@ export class CommentView implements IRenderedElement { /** The resize handle element. */ private resizeHandle: SVGImageElement; - /** The foreignObject containing the HTML text area. */ - private foreignObject: SVGForeignObjectElement; - - /** The text area where the user can type. */ - private textArea: HTMLTextAreaElement; + /** The part of the comment view that contains the textarea to edit the comment. */ + private commentEditor: CommentEditor; /** The current size of the comment in workspace units. */ private size: Size; @@ -64,14 +63,6 @@ export class CommentView implements IRenderedElement { /** The current location of the comment in workspace coordinates. */ private location: Coordinate = new Coordinate(0, 0); - /** The current text of the comment. Updates on text area change. */ - private text: string = ''; - - /** Listeners for changes to text. */ - private textChangeListeners: Array< - (oldText: string, newText: string) => void - > = []; - /** Listeners for changes to size. */ private sizeChangeListeners: Array<(oldSize: Size, newSize: Size) => void> = []; @@ -106,7 +97,10 @@ export class CommentView implements IRenderedElement { /** The default size of newly created comments. */ static defaultCommentSize = new Size(120, 100); - constructor(readonly workspace: WorkspaceSvg) { + constructor( + readonly workspace: WorkspaceSvg, + private commentId?: string, + ) { this.svgRoot = dom.createSvgElement(Svg.G, { 'class': 'blocklyComment blocklyEditable blocklyDraggable', }); @@ -122,8 +116,7 @@ export class CommentView implements IRenderedElement { textPreviewNode: this.textPreviewNode, } = this.createTopBar(this.svgRoot, workspace)); - ({foreignObject: this.foreignObject, textArea: this.textArea} = - this.createTextArea(this.svgRoot)); + this.commentEditor = this.createTextArea(); this.resizeHandle = this.createResizeHandle(this.svgRoot, workspace); @@ -236,33 +229,32 @@ export class CommentView implements IRenderedElement { /** * Creates the text area where users can type. Registers event listeners. */ - private createTextArea(svgRoot: SVGGElement): { - foreignObject: SVGForeignObjectElement; - textArea: HTMLTextAreaElement; - } { - const foreignObject = dom.createSvgElement( - Svg.FOREIGNOBJECT, - { - 'class': 'blocklyCommentForeignObject', - }, - svgRoot, + private createTextArea() { + // When the user is done editing comment, focus the entire comment. + const onFinishEditing = () => this.svgRoot.focus(); + const commentEditor = new CommentEditor( + this.workspace, + this.commentId, + onFinishEditing, ); - const body = document.createElementNS(dom.HTML_NS, 'body'); - body.setAttribute('xmlns', dom.HTML_NS); - body.className = 'blocklyMinimalBody'; - const textArea = document.createElementNS( - dom.HTML_NS, - 'textarea', - ) as HTMLTextAreaElement; - dom.addClass(textArea, 'blocklyCommentText'); - dom.addClass(textArea, 'blocklyTextarea'); - dom.addClass(textArea, 'blocklyText'); - body.appendChild(textArea); - foreignObject.appendChild(body); - browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange); + this.svgRoot.appendChild(commentEditor.getDom()); - return {foreignObject, textArea}; + commentEditor.addTextChangeListener((oldText, newText) => { + this.updateTextPreview(newText); + // Update size in case our minimum size increased. + this.setSize(this.size); + }); + + return commentEditor; + } + + /** + * + * @returns The FocusableNode representing the editor portion of this comment. + */ + getEditorFocusableNode(): IFocusableNode { + return this.commentEditor; } /** Creates the DOM elements for the comment resize handle. */ @@ -324,7 +316,7 @@ export class CommentView implements IRenderedElement { this.updateHighlightRect(size); this.updateTopBarSize(size); - this.updateTextAreaSize(size, topBarSize); + this.commentEditor.updateSize(size, topBarSize); this.updateDeleteIconPosition(size, topBarSize, deleteSize); this.updateFoldoutIconPosition(topBarSize, foldoutSize); this.updateTextPreviewSize( @@ -360,7 +352,7 @@ export class CommentView implements IRenderedElement { foldoutSize: Size, deleteSize: Size, ): Size { - this.updateTextPreview(this.textArea.value ?? ''); + this.updateTextPreview(this.commentEditor.getText() ?? ''); const textPreviewWidth = dom.getTextWidth(this.textPreview); const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); @@ -408,19 +400,6 @@ export class CommentView implements IRenderedElement { this.topBarBackground.setAttribute('width', `${size.width}`); } - /** Updates the size of the text area elements to reflect the new size. */ - private updateTextAreaSize(size: Size, topBarSize: Size) { - this.foreignObject.setAttribute( - 'height', - `${size.height - topBarSize.height}`, - ); - this.foreignObject.setAttribute('width', `${size.width}`); - this.foreignObject.setAttribute('y', `${topBarSize.height}`); - if (this.workspace.RTL) { - this.foreignObject.setAttribute('x', `${-size.width}`); - } - } - /** * Updates the position of the delete icon elements to reflect the new size. */ @@ -652,12 +631,11 @@ export class CommentView implements IRenderedElement { if (this.editable) { dom.addClass(this.svgRoot, 'blocklyEditable'); dom.removeClass(this.svgRoot, 'blocklyReadonly'); - this.textArea.removeAttribute('readonly'); } else { dom.removeClass(this.svgRoot, 'blocklyEditable'); dom.addClass(this.svgRoot, 'blocklyReadonly'); - this.textArea.setAttribute('readonly', 'true'); } + this.commentEditor.setEditable(editable); } /** Returns the current location of the comment in workspace coordinates. */ @@ -678,49 +656,29 @@ export class CommentView implements IRenderedElement { ); } - /** Retursn the current text of the comment. */ + /** Returns the current text of the comment. */ getText() { - return this.text; + return this.commentEditor.getText(); } /** Sets the current text of the comment. */ setText(text: string) { - this.textArea.value = text; - this.onTextChange(); + this.commentEditor.setText(text); } /** Sets the placeholder text displayed for an empty comment. */ setPlaceholderText(text: string) { - this.textArea.placeholder = text; + this.commentEditor.setPlaceholderText(text); } - /** Registers a callback that listens for text changes. */ + /** Registers a callback that listens for text changes on the comment editor. */ addTextChangeListener(listener: (oldText: string, newText: string) => void) { - this.textChangeListeners.push(listener); + this.commentEditor.addTextChangeListener(listener); } - /** Removes the given listener from the list of text change listeners. */ + /** Removes the given listener from the comment editor. */ removeTextChangeListener(listener: () => void) { - this.textChangeListeners.splice( - this.textChangeListeners.indexOf(listener), - 1, - ); - } - - /** - * Triggers listeners when the text of the comment changes, either - * programmatically or manually by the user. - */ - private onTextChange() { - const oldText = this.text; - this.text = this.textArea.value; - this.updateTextPreview(this.text); - // Update size in case our minimum size increased. - this.setSize(this.size); - // Loop through listeners backwards in case they remove themselves. - for (let i = this.textChangeListeners.length - 1; i >= 0; i--) { - this.textChangeListeners[i](oldText, this.text); - } + this.commentEditor.removeTextChangeListener(listener); } /** Updates the preview text element to reflect the given text. */ @@ -884,6 +842,11 @@ css.register(` fill: none; } +.blocklyCommentText.blocklyActiveFocus { + border-color: #fc3; + border-width: 2px; +} + .blocklySelected .blocklyCommentHighlight { stroke: #fc3; stroke-width: 3px; diff --git a/core/comments/rendered_workspace_comment.ts b/core/comments/rendered_workspace_comment.ts index 42fb1fda47c..3457e611a7e 100644 --- a/core/comments/rendered_workspace_comment.ts +++ b/core/comments/rendered_workspace_comment.ts @@ -47,7 +47,7 @@ export class RenderedWorkspaceComment IFocusableNode { /** The class encompassing the svg elements making up the workspace comment. */ - private view: CommentView; + view: CommentView; public readonly workspace: WorkspaceSvg; @@ -59,7 +59,7 @@ export class RenderedWorkspaceComment this.workspace = workspace; - this.view = new CommentView(workspace); + this.view = new CommentView(workspace, this.id); // Set the size to the default size as defined in the superclass. this.view.setSize(this.getSize()); this.view.setEditable(this.isEditable()); @@ -224,13 +224,7 @@ export class RenderedWorkspaceComment private startGesture(e: PointerEvent) { const gesture = this.workspace.getGesture(e); if (gesture) { - if (browserEvents.isTargetInput(e)) { - // If the text area was the focus, don't allow this event to bubble up - // and steal focus away from the editor/comment. - e.stopPropagation(); - } else { - gesture.handleCommentStart(e, this); - } + gesture.handleCommentStart(e, this); getFocusManager().focusNode(this); } } @@ -339,6 +333,13 @@ export class RenderedWorkspaceComment } } + /** + * @returns The FocusableNode representing the editor portion of this comment. + */ + getEditorFocusableNode(): IFocusableNode { + return this.view.getEditorFocusableNode(); + } + /** See IFocusableNode.getFocusableElement. */ getFocusableElement(): HTMLElement | SVGElement { return this.getSvgRoot(); diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 552d3706184..3033eacd74f 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -22,6 +22,7 @@ import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; import type {BlocklyOptions} from './blockly_options.js'; import * as browserEvents from './browser_events.js'; +import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js'; import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; import {WorkspaceComment} from './comments/workspace_comment.js'; import * as common from './common.js'; @@ -2729,6 +2730,26 @@ export class WorkspaceSvg return nestedWorkspaces; } + /** + * Used for searching for a specific workspace comment. + * We can't use this.getWorkspaceCommentById because the workspace + * comment ids might not be globally unique, but the id assigned to + * the focusable element for the comment should be. + */ + private searchForWorkspaceComment( + id: string, + ): RenderedWorkspaceComment | undefined { + for (const comment of this.getTopComments()) { + if ( + comment instanceof RenderedWorkspaceComment && + comment.canBeFocused() && + comment.getFocusableElement().id === id + ) { + return comment; + } + } + } + /** See IFocusableTree.lookUpFocusableNode. */ lookUpFocusableNode(id: string): IFocusableNode | null { // Check against flyout items if this workspace is part of a flyout. Note @@ -2773,21 +2794,29 @@ export class WorkspaceSvg return null; } + // Search for a specific workspace comment editor + // (only if id seems like it is one). + const commentEditorIndicator = id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER); + if (commentEditorIndicator !== -1) { + const commentId = id.substring(0, commentEditorIndicator); + const comment = this.searchForWorkspaceComment(commentId); + if (comment) { + return comment.getEditorFocusableNode(); + } + } + // Search for a specific block. + // Don't use `getBlockById` because the block ID is not guaranteeed + // to be globally unique, but the ID on the focusable element is. const block = this.getAllBlocks(false).find( (block) => block.getFocusableElement().id === id, ); if (block) return block; // Search for a workspace comment (semi-expensive). - for (const comment of this.getTopComments()) { - if ( - comment instanceof RenderedWorkspaceComment && - comment.canBeFocused() && - comment.getFocusableElement().id === id - ) { - return comment; - } + const comment = this.searchForWorkspaceComment(id); + if (comment) { + return comment; } // Search for icons and bubbles (which requires an expensive getAllBlocks). From f4dbea0a65ad4808f8318f3bbe6aa5000b24c235 Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Wed, 25 Jun 2025 04:49:37 -0700 Subject: [PATCH 28/67] refactor(interfaces): Make type predicates more robust (#9150) * refactor(interfaces): Use typeof ... === 'function' to test for methods Testing for 'name' in object or obj.name !== undefined only checks for the existence of the property (and in the latter case that the property is not set to undefined). That's fine if the interface specifies a property of indeterminate type, but in the usual case that the interface member is a method we can do one better and check to make sure the property's value is callable. * refactor(interfaces): Always check obj is not null/undefined Since most type predicates take an argument of type any but then check for the existence of certain properties, explicitly check that the argument is not null or undefined (or check implicitly by calling another type predicate that does so first, which necessitates adding a few casts because tsc infers the type of the argument too narrowly). * fix(interfaces): Add missing check to hasBubble type predicate This appears to have inadvertently been omitted in PR #9004. * fix(interfaces): Fix misplaced typeof * fix: Fix typos in JSDocs * fix(tests): Make Mocks conform to corresponding interfaces Introduce a new MockFocusable, and add methods to MockIcon, MockBubbleIcon and MockComment, so that they fulfil the IFocusableNode, IIcon, IHasBubble and ICommentIcon interfaces respectively. * chore(tests): Add assertions verifying mocks conform to predicates Add (test) runtime assertions that: - isFocusableNode(MockFocusable) returns true - isIcon(MockIcon) returns true - hasBubble(MockBubbleIcon) returns true - isCommentIcon(MockCommentIcon) returns true (The latter is currently failing because Blockly is undefined when isCommentIcon calls the MockCommentIcon's getType method.) * fix(tests): Don't rely on Blockly being set in Mock methods For some reason the global Blockly binding is not visible at the time when isCommentIcon calls MockCommentIcon's getType method, and presumably this problem would apply to getBubbleSize too, so directly import the required items. * refactor(tests): Make MockCommentIcon a MockBubbleIcon This slightly simplifies it and makes it less likely to accidentally stop conforming to IHasBubble. * fix(interfaces): Fix incorrect check in isSelectable Fix an error which caused ISelectable instances to fail isSelectable() checks, one of the results of which is that Blockly.common.getSelected() would generally return null. Whoops! --- core/interfaces/i_autohideable.ts | 2 +- core/interfaces/i_comment_icon.ts | 14 ++++---- core/interfaces/i_copyable.ts | 2 +- core/interfaces/i_deletable.ts | 7 ++-- core/interfaces/i_draggable.ts | 13 ++++---- core/interfaces/i_focusable_node.ts | 16 ++++----- core/interfaces/i_focusable_tree.ts | 18 +++++----- core/interfaces/i_has_bubble.ts | 4 ++- core/interfaces/i_icon.ts | 28 ++++++++-------- core/interfaces/i_legacy_procedure_blocks.ts | 15 +++++---- core/interfaces/i_observable.ts | 6 +++- core/interfaces/i_paster.ts | 2 +- core/interfaces/i_procedure_block.ts | 7 ++-- core/interfaces/i_rendered_element.ts | 2 +- core/interfaces/i_selectable.ts | 12 +++---- core/interfaces/i_serializable.ts | 6 +++- tests/mocha/block_test.js | 19 ++++++----- tests/mocha/test_helpers/icon_mocks.js | 35 +++++++++++++++++++- 18 files changed, 128 insertions(+), 80 deletions(-) diff --git a/core/interfaces/i_autohideable.ts b/core/interfaces/i_autohideable.ts index 41e761f57ca..1193023d21b 100644 --- a/core/interfaces/i_autohideable.ts +++ b/core/interfaces/i_autohideable.ts @@ -23,5 +23,5 @@ export interface IAutoHideable extends IComponent { /** Returns true if the given object is autohideable. */ export function isAutoHideable(obj: any): obj is IAutoHideable { - return obj.autoHide !== undefined; + return obj && typeof obj.autoHide === 'function'; } diff --git a/core/interfaces/i_comment_icon.ts b/core/interfaces/i_comment_icon.ts index 05f86f40ff9..1ab5bead447 100644 --- a/core/interfaces/i_comment_icon.ts +++ b/core/interfaces/i_comment_icon.ts @@ -31,17 +31,17 @@ export interface ICommentIcon extends IIcon, IHasBubble, ISerializable { } /** Checks whether the given object is an ICommentIcon. */ -export function isCommentIcon(obj: object): obj is ICommentIcon { +export function isCommentIcon(obj: any): obj is ICommentIcon { return ( isIcon(obj) && hasBubble(obj) && isSerializable(obj) && - (obj as any)['setText'] !== undefined && - (obj as any)['getText'] !== undefined && - (obj as any)['setBubbleSize'] !== undefined && - (obj as any)['getBubbleSize'] !== undefined && - (obj as any)['setBubbleLocation'] !== undefined && - (obj as any)['getBubbleLocation'] !== undefined && + typeof (obj as any).setText === 'function' && + typeof (obj as any).getText === 'function' && + typeof (obj as any).setBubbleSize === 'function' && + typeof (obj as any).getBubbleSize === 'function' && + typeof (obj as any).setBubbleLocation === 'function' && + typeof (obj as any).getBubbleLocation === 'function' && obj.getType() === IconType.COMMENT ); } diff --git a/core/interfaces/i_copyable.ts b/core/interfaces/i_copyable.ts index 6c354926a64..8d1853967d4 100644 --- a/core/interfaces/i_copyable.ts +++ b/core/interfaces/i_copyable.ts @@ -35,5 +35,5 @@ export type ICopyData = ICopyable.ICopyData; /** @returns true if the given object is an ICopyable. */ export function isCopyable(obj: any): obj is ICopyable { - return obj.toCopyData !== undefined; + return obj && typeof obj.toCopyData === 'function'; } diff --git a/core/interfaces/i_deletable.ts b/core/interfaces/i_deletable.ts index 0467709409a..156e43ddc50 100644 --- a/core/interfaces/i_deletable.ts +++ b/core/interfaces/i_deletable.ts @@ -27,8 +27,9 @@ export interface IDeletable { /** Returns whether the given object is an IDeletable. */ export function isDeletable(obj: any): obj is IDeletable { return ( - obj['isDeletable'] !== undefined && - obj['dispose'] !== undefined && - obj['setDeleteStyle'] !== undefined + obj && + typeof obj.isDeletable === 'function' && + typeof obj.dispose === 'function' && + typeof obj.setDeleteStyle === 'function' ); } diff --git a/core/interfaces/i_draggable.ts b/core/interfaces/i_draggable.ts index cb723e7b88b..9130381163f 100644 --- a/core/interfaces/i_draggable.ts +++ b/core/interfaces/i_draggable.ts @@ -62,11 +62,12 @@ export interface IDragStrategy { /** Returns whether the given object is an IDraggable or not. */ export function isDraggable(obj: any): obj is IDraggable { return ( - obj.getRelativeToSurfaceXY !== undefined && - obj.isMovable !== undefined && - obj.startDrag !== undefined && - obj.drag !== undefined && - obj.endDrag !== undefined && - obj.revertDrag !== undefined + obj && + typeof obj.getRelativeToSurfaceXY === 'function' && + typeof obj.isMovable === 'function' && + typeof obj.startDrag === 'function' && + typeof obj.drag === 'function' && + typeof obj.endDrag === 'function' && + typeof obj.revertDrag === 'function' ); } diff --git a/core/interfaces/i_focusable_node.ts b/core/interfaces/i_focusable_node.ts index 00557168afa..24833328d7f 100644 --- a/core/interfaces/i_focusable_node.ts +++ b/core/interfaces/i_focusable_node.ts @@ -102,16 +102,16 @@ export interface IFocusableNode { * Determines whether the provided object fulfills the contract of * IFocusableNode. * - * @param object The object to test. + * @param obj The object to test. * @returns Whether the provided object can be used as an IFocusableNode. */ -export function isFocusableNode(object: any | null): object is IFocusableNode { +export function isFocusableNode(obj: any): obj is IFocusableNode { return ( - object && - 'getFocusableElement' in object && - 'getFocusableTree' in object && - 'onNodeFocus' in object && - 'onNodeBlur' in object && - 'canBeFocused' in object + obj && + typeof obj.getFocusableElement === 'function' && + typeof obj.getFocusableTree === 'function' && + typeof obj.onNodeFocus === 'function' && + typeof obj.onNodeBlur === 'function' && + typeof obj.canBeFocused === 'function' ); } diff --git a/core/interfaces/i_focusable_tree.ts b/core/interfaces/i_focusable_tree.ts index f4f25f7f518..c33189fcdf0 100644 --- a/core/interfaces/i_focusable_tree.ts +++ b/core/interfaces/i_focusable_tree.ts @@ -128,17 +128,17 @@ export interface IFocusableTree { * Determines whether the provided object fulfills the contract of * IFocusableTree. * - * @param object The object to test. + * @param obj The object to test. * @returns Whether the provided object can be used as an IFocusableTree. */ -export function isFocusableTree(object: any | null): object is IFocusableTree { +export function isFocusableTree(obj: any): obj is IFocusableTree { return ( - object && - 'getRootFocusableNode' in object && - 'getRestoredFocusableNode' in object && - 'getNestedTrees' in object && - 'lookUpFocusableNode' in object && - 'onTreeFocus' in object && - 'onTreeBlur' in object + obj && + typeof obj.getRootFocusableNode === 'function' && + typeof obj.getRestoredFocusableNode === 'function' && + typeof obj.getNestedTrees === 'function' && + typeof obj.lookUpFocusableNode === 'function' && + typeof obj.onTreeFocus === 'function' && + typeof obj.onTreeBlur === 'function' ); } diff --git a/core/interfaces/i_has_bubble.ts b/core/interfaces/i_has_bubble.ts index 85c6f099031..0c2e257a440 100644 --- a/core/interfaces/i_has_bubble.ts +++ b/core/interfaces/i_has_bubble.ts @@ -30,6 +30,8 @@ export interface IHasBubble { /** Type guard that checks whether the given object is a IHasBubble. */ export function hasBubble(obj: any): obj is IHasBubble { return ( - obj.bubbleIsVisible !== undefined && obj.setBubbleVisible !== undefined + typeof obj.bubbleIsVisible === 'function' && + typeof obj.setBubbleVisible === 'function' && + typeof obj.getBubble === 'function' ); } diff --git a/core/interfaces/i_icon.ts b/core/interfaces/i_icon.ts index 74489dc5e09..06f416424ef 100644 --- a/core/interfaces/i_icon.ts +++ b/core/interfaces/i_icon.ts @@ -98,19 +98,19 @@ export interface IIcon extends IFocusableNode { /** Type guard that checks whether the given object is an IIcon. */ export function isIcon(obj: any): obj is IIcon { return ( - obj.getType !== undefined && - obj.initView !== undefined && - obj.dispose !== undefined && - obj.getWeight !== undefined && - obj.getSize !== undefined && - obj.applyColour !== undefined && - obj.hideForInsertionMarker !== undefined && - obj.updateEditable !== undefined && - obj.updateCollapsed !== undefined && - obj.isShownWhenCollapsed !== undefined && - obj.setOffsetInBlock !== undefined && - obj.onLocationChange !== undefined && - obj.onClick !== undefined && - isFocusableNode(obj) + isFocusableNode(obj) && + typeof (obj as IIcon).getType === 'function' && + typeof (obj as IIcon).initView === 'function' && + typeof (obj as IIcon).dispose === 'function' && + typeof (obj as IIcon).getWeight === 'function' && + typeof (obj as IIcon).getSize === 'function' && + typeof (obj as IIcon).applyColour === 'function' && + typeof (obj as IIcon).hideForInsertionMarker === 'function' && + typeof (obj as IIcon).updateEditable === 'function' && + typeof (obj as IIcon).updateCollapsed === 'function' && + typeof (obj as IIcon).isShownWhenCollapsed === 'function' && + typeof (obj as IIcon).setOffsetInBlock === 'function' && + typeof (obj as IIcon).onLocationChange === 'function' && + typeof (obj as IIcon).onClick === 'function' ); } diff --git a/core/interfaces/i_legacy_procedure_blocks.ts b/core/interfaces/i_legacy_procedure_blocks.ts index d74eaec220a..c723a5ed77c 100644 --- a/core/interfaces/i_legacy_procedure_blocks.ts +++ b/core/interfaces/i_legacy_procedure_blocks.ts @@ -28,9 +28,9 @@ export interface LegacyProcedureDefBlock { /** @internal */ export function isLegacyProcedureDefBlock( - block: object, -): block is LegacyProcedureDefBlock { - return (block as any).getProcedureDef !== undefined; + obj: any, +): obj is LegacyProcedureDefBlock { + return obj && typeof obj.getProcedureDef === 'function'; } /** @internal */ @@ -41,10 +41,11 @@ export interface LegacyProcedureCallBlock { /** @internal */ export function isLegacyProcedureCallBlock( - block: object, -): block is LegacyProcedureCallBlock { + obj: any, +): obj is LegacyProcedureCallBlock { return ( - (block as any).getProcedureCall !== undefined && - (block as any).renameProcedure !== undefined + obj && + typeof obj.getProcedureCall === 'function' && + typeof obj.renameProcedure === 'function' ); } diff --git a/core/interfaces/i_observable.ts b/core/interfaces/i_observable.ts index 96a2a0bc4e8..8db0c237874 100644 --- a/core/interfaces/i_observable.ts +++ b/core/interfaces/i_observable.ts @@ -20,5 +20,9 @@ export interface IObservable { * @internal */ export function isObservable(obj: any): obj is IObservable { - return obj.startPublishing !== undefined && obj.stopPublishing !== undefined; + return ( + obj && + typeof obj.startPublishing === 'function' && + typeof obj.stopPublishing === 'function' + ); } diff --git a/core/interfaces/i_paster.ts b/core/interfaces/i_paster.ts index 321ff118f70..128913a26b1 100644 --- a/core/interfaces/i_paster.ts +++ b/core/interfaces/i_paster.ts @@ -21,5 +21,5 @@ export interface IPaster> { export function isPaster( obj: any, ): obj is IPaster> { - return obj.paste !== undefined; + return obj && typeof obj.paste === 'function'; } diff --git a/core/interfaces/i_procedure_block.ts b/core/interfaces/i_procedure_block.ts index f8538052749..3a6dc4847b9 100644 --- a/core/interfaces/i_procedure_block.ts +++ b/core/interfaces/i_procedure_block.ts @@ -20,9 +20,10 @@ export interface IProcedureBlock { export function isProcedureBlock( block: Block | IProcedureBlock, ): block is IProcedureBlock { + block = block as IProcedureBlock; return ( - (block as IProcedureBlock).getProcedureModel !== undefined && - (block as IProcedureBlock).doProcedureUpdate !== undefined && - (block as IProcedureBlock).isProcedureDef !== undefined + typeof block.getProcedureModel === 'function' && + typeof block.doProcedureUpdate === 'function' && + typeof block.isProcedureDef === 'function' ); } diff --git a/core/interfaces/i_rendered_element.ts b/core/interfaces/i_rendered_element.ts index fe9460c7f6a..2f82487e9be 100644 --- a/core/interfaces/i_rendered_element.ts +++ b/core/interfaces/i_rendered_element.ts @@ -15,5 +15,5 @@ export interface IRenderedElement { * @returns True if the given object is an IRenderedElement. */ export function isRenderedElement(obj: any): obj is IRenderedElement { - return obj['getSvgRoot'] !== undefined; + return obj && typeof obj.getSvgRoot === 'function'; } diff --git a/core/interfaces/i_selectable.ts b/core/interfaces/i_selectable.ts index 639972e45cb..5374f50cd3a 100644 --- a/core/interfaces/i_selectable.ts +++ b/core/interfaces/i_selectable.ts @@ -30,12 +30,12 @@ export interface ISelectable extends IFocusableNode { } /** Checks whether the given object is an ISelectable. */ -export function isSelectable(obj: object): obj is ISelectable { +export function isSelectable(obj: any): obj is ISelectable { return ( - typeof (obj as any).id === 'string' && - (obj as any).workspace !== undefined && - (obj as any).select !== undefined && - (obj as any).unselect !== undefined && - isFocusableNode(obj) + isFocusableNode(obj) && + typeof (obj as ISelectable).id === 'string' && + typeof (obj as ISelectable).workspace === 'object' && + typeof (obj as ISelectable).select === 'function' && + typeof (obj as ISelectable).unselect === 'function' ); } diff --git a/core/interfaces/i_serializable.ts b/core/interfaces/i_serializable.ts index 380a277095d..99e597da37a 100644 --- a/core/interfaces/i_serializable.ts +++ b/core/interfaces/i_serializable.ts @@ -24,5 +24,9 @@ export interface ISerializable { /** Type guard that checks whether the given object is a ISerializable. */ export function isSerializable(obj: any): obj is ISerializable { - return obj.saveState !== undefined && obj.loadState !== undefined; + return ( + obj && + typeof obj.saveState === 'function' && + typeof obj.loadState === 'function' + ); } diff --git a/tests/mocha/block_test.js b/tests/mocha/block_test.js index eda2d82a56d..62c61ce004c 100644 --- a/tests/mocha/block_test.js +++ b/tests/mocha/block_test.js @@ -7,7 +7,10 @@ import {ConnectionType} from '../../build/src/core/connection_type.js'; import {EventType} from '../../build/src/core/events/type.js'; import * as eventUtils from '../../build/src/core/events/utils.js'; +import {IconType} from '../../build/src/core/icons/icon_types.js'; import {EndRowInput} from '../../build/src/core/inputs/end_row_input.js'; +import {isCommentIcon} from '../../build/src/core/interfaces/i_comment_icon.js'; +import {Size} from '../../build/src/core/utils/size.js'; import {assert} from '../../node_modules/chai/chai.js'; import {createRenderedBlock} from './test_helpers/block_definitions.js'; import { @@ -1426,9 +1429,9 @@ suite('Blocks', function () { }); suite('Constructing registered comment classes', function () { - class MockComment extends MockIcon { + class MockComment extends MockBubbleIcon { getType() { - return Blockly.icons.IconType.COMMENT; + return IconType.COMMENT; } setText() {} @@ -1440,19 +1443,13 @@ suite('Blocks', function () { setBubbleSize() {} getBubbleSize() { - return Blockly.utils.Size(0, 0); + return Size(0, 0); } setBubbleLocation() {} getBubbleLocation() {} - bubbleIsVisible() { - return true; - } - - setBubbleVisible() {} - saveState() { return {}; } @@ -1460,6 +1457,10 @@ suite('Blocks', function () { loadState() {} } + if (!isCommentIcon(new MockComment())) { + throw new TypeError('MockComment not an ICommentIcon'); + } + setup(function () { this.workspace = Blockly.inject('blocklyDiv', {}); diff --git a/tests/mocha/test_helpers/icon_mocks.js b/tests/mocha/test_helpers/icon_mocks.js index 5d117c71284..0e549b9764c 100644 --- a/tests/mocha/test_helpers/icon_mocks.js +++ b/tests/mocha/test_helpers/icon_mocks.js @@ -4,7 +4,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -export class MockIcon { +import {isFocusableNode} from '../../../build/src/core/interfaces/i_focusable_node.js'; +import {hasBubble} from '../../../build/src/core/interfaces/i_has_bubble.js'; +import {isIcon} from '../../../build/src/core/interfaces/i_icon.js'; +import {isSerializable} from '../../../build/src/core/interfaces/i_serializable.js'; + +export class MockFocusable { + getFocusableElement() {} + getFocusableTree() {} + onNodeFocus() {} + onNodeBlur() {} + canBeFocused() {} +} + +if (!isFocusableNode(new MockFocusable())) { + throw new TypeError('MockFocusable not an IFocuableNode'); +} + +export class MockIcon extends MockFocusable { getType() { return new Blockly.icons.IconType('mock icon'); } @@ -52,6 +69,10 @@ export class MockIcon { } } +if (!isIcon(new MockIcon())) { + throw new TypeError('MockIcon not an IIcon'); +} + export class MockSerializableIcon extends MockIcon { constructor() { super(); @@ -75,6 +96,10 @@ export class MockSerializableIcon extends MockIcon { } } +if (!isSerializable(new MockSerializableIcon())) { + throw new TypeError('MockSerializableIcon not an ISerializable'); +} + export class MockBubbleIcon extends MockIcon { constructor() { super(); @@ -94,4 +119,12 @@ export class MockBubbleIcon extends MockIcon { setBubbleVisible(visible) { this.visible = visible; } + + getBubble() { + return null; + } +} + +if (!hasBubble(new MockBubbleIcon())) { + throw new TypeError('MockBubbleIcon not an IHasBubble'); } From 9cc3e11856413d8a950423b64a5eac02ab9c55a5 Mon Sep 17 00:00:00 2001 From: Matt Hillsdon Date: Thu, 26 Jun 2025 19:41:01 +0100 Subject: [PATCH 29/67] fix: tweak redo shortcut order to match convention (#9169) The order of the modifiers is not significant to Blockly but it's conventional to say e.g. Cmd+Shift+Z. Following that order here means that UI like the keyboard navigation shortcut dialog gets the correct order without having to sort. --- core/shortcut_items.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 25295e417c0..062d0cb4e77 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -344,12 +344,12 @@ export function registerUndo() { */ export function registerRedo() { const ctrlShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.SHIFT, KeyCodes.CTRL, + KeyCodes.SHIFT, ]); const metaShiftZ = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Z, [ - KeyCodes.SHIFT, KeyCodes.META, + KeyCodes.SHIFT, ]); // Ctrl-y is redo in Windows. Command-y is never valid on Macs. const ctrlY = ShortcutRegistry.registry.createSerializedKey(KeyCodes.Y, [ From 0d6da6cfc4770bb5af038ecc9cbb0d3e2742991c Mon Sep 17 00:00:00 2001 From: Richard Knoll Date: Thu, 26 Jun 2025 13:56:08 -0700 Subject: [PATCH 30/67] fix: clear touch identifier on comment text area pointerdown (#9172) --- core/bubbles/textinput_bubble.ts | 1 + core/comments/comment_editor.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/core/bubbles/textinput_bubble.ts b/core/bubbles/textinput_bubble.ts index 4946ee458b1..7479c06cfc5 100644 --- a/core/bubbles/textinput_bubble.ts +++ b/core/bubbles/textinput_bubble.ts @@ -176,6 +176,7 @@ export class TextInputBubble extends Bubble { // Don't let the pointerdown event get to the workspace. browserEvents.conditionalBind(textArea, 'pointerdown', this, (e: Event) => { e.stopPropagation(); + touch.clearTouchIdentifier(); }); browserEvents.conditionalBind(textArea, 'change', this, this.onTextChange); diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts index f921168fa13..9a1907e9117 100644 --- a/core/comments/comment_editor.ts +++ b/core/comments/comment_editor.ts @@ -8,6 +8,7 @@ import * as browserEvents from '../browser_events.js'; import {getFocusManager} from '../focus_manager.js'; import {IFocusableNode} from '../interfaces/i_focusable_node.js'; import {IFocusableTree} from '../interfaces/i_focusable_tree.js'; +import * as touch from '../touch.js'; import * as dom from '../utils/dom.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; @@ -80,6 +81,7 @@ export class CommentEditor implements IFocusableNode { // and steal focus away from the editor/comment. e.stopPropagation(); getFocusManager().focusNode(this); + touch.clearTouchIdentifier(); }, ); From 8015956b16a0ad75a25c07d7f89cff4b466c168a Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Thu, 26 Jun 2025 13:59:46 -0700 Subject: [PATCH 31/67] release: Update version number to 12.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f8de5349ba..4719cce8c9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "blockly", - "version": "12.1.0", + "version": "12.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockly", - "version": "12.1.0", + "version": "12.2.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index eab39b16cde..7e80ff2cd2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blockly", - "version": "12.1.0", + "version": "12.2.0", "description": "Blockly is a library for building visual programming editors.", "keywords": [ "blockly" From 9b18a9b75a6ece875acbe1ba1e37543963a4df99 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Mon, 23 Jun 2025 12:24:10 -0700 Subject: [PATCH 32/67] Work on fixing more browser tests --- tests/browser/test/delete_blocks_test.mjs | 3 +- tests/browser/test/procedure_test.mjs | 14 ++++--- tests/browser/test/test_setup.mjs | 50 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/tests/browser/test/delete_blocks_test.mjs b/tests/browser/test/delete_blocks_test.mjs index a407ad0600f..133716fa875 100644 --- a/tests/browser/test/delete_blocks_test.mjs +++ b/tests/browser/test/delete_blocks_test.mjs @@ -194,7 +194,8 @@ suite('Delete blocks', function (done) { ); }); - test('Redo block deletion', async function () { + // TODO(#9029) enable this test once deleting a block doesn't lose focus + test.skip('Redo block deletion', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. await clickBlock(this.browser, this.firstBlock.id, {button: 1}); diff --git a/tests/browser/test/procedure_test.mjs b/tests/browser/test/procedure_test.mjs index c01eb49561c..c31ef6d736b 100644 --- a/tests/browser/test/procedure_test.mjs +++ b/tests/browser/test/procedure_test.mjs @@ -12,6 +12,7 @@ import * as chai from 'chai'; import { connect, getBlockTypeFromCategory, + getDraggableBlockElementByType, getNthBlockOfCategory, getSelectedBlockElement, PAUSE_TIME, @@ -31,9 +32,10 @@ suite('Testing Connecting Blocks', function (done) { this.browser.on('dialog', (dialog) => {}); }); - test('Testing Procedure', async function () { + test.only('Testing Procedure', async function () { + // Drag out first function - let proceduresDefReturn = await getBlockTypeFromCategory( + let proceduresDefReturn = await getDraggableBlockElementByType( this.browser, 'Functions', 'procedures_defreturn', @@ -42,12 +44,12 @@ suite('Testing Connecting Blocks', function (done) { const doSomething = await getSelectedBlockElement(this.browser); // Drag out second function. - proceduresDefReturn = await getBlockTypeFromCategory( + proceduresDefReturn = await getDraggableBlockElementByType( this.browser, 'Functions', 'procedures_defreturn', ); - await proceduresDefReturn.dragAndDrop({x: 300, y: 200}); + await proceduresDefReturn.dragAndDrop({x: 50, y: 20}); const doSomething2 = await getSelectedBlockElement(this.browser); // Drag out numeric @@ -86,7 +88,7 @@ suite('Testing Connecting Blocks', function (done) { 'Text', 'text_print', ); - await printFlyout.dragAndDrop({x: 50, y: 20}); + await printFlyout.dragAndDrop({x: 50, y: 0}); const print = await getSelectedBlockElement(this.browser); // Drag out doSomething2 caller from flyout. @@ -95,7 +97,7 @@ suite('Testing Connecting Blocks', function (done) { 'Functions', 4, ); - await doSomething2Flyout.dragAndDrop({x: 130, y: 20}); + await doSomething2Flyout.dragAndDrop({x: 50, y: 20}); const doSomething2Caller = await getSelectedBlockElement(this.browser); // Connect doSomething2 caller with print. diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index 04a192a46a7..edfad4d1b4c 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -286,6 +286,7 @@ export async function getBlockTypeFromCategory( await category.click(); } + await browser.pause(PAUSE_TIME); const id = await browser.execute((blockType) => { return Blockly.getMainWorkspace() .getFlyout() @@ -295,6 +296,55 @@ export async function getBlockTypeFromCategory( return getBlockElementById(browser, id); } +/** + * @param browser The active WebdriverIO Browser object. + * @param categoryName The name of the toolbox category to search. + * Null if the toolbox has no categories (simple). + * @param blockType The type of the block to search for. + * @return A Promise that resolves to a reasonable drag target element of the + * first block with the given type in the given category. + */ +export async function getDraggableBlockElementByType( + browser, + categoryName, + blockType, +) { + if (categoryName) { + const category = await getCategory(browser, categoryName); + await category.click(); + } + + const findableId = 'dragTargetElement'; + // In the browser context, find the element that we want and give it a findable ID. + await browser.execute( + (blockType, newElemId) => { + const block = Blockly.getMainWorkspace() + .getFlyout() + .getWorkspace() + .getBlocksByType(blockType)[0]; + if (!block.isCollapsed()) { + for (const input of block.inputList) { + for (const field of input.fieldRow) { + if (field instanceof Blockly.FieldLabel) { + const svgRoot = field.getSvgRoot(); + if (svgRoot) { + svgRoot.id = newElemId; + return; + } + } + } + } + } + // No label field found. Fall back to the block's SVG root. + block.getSvgRoot().id = newElemId; + }, + blockType, + findableId, + ); + // In the test context, get the Webdriverio Element that we've identified. + return await browser.$(`#${findableId}`); +} + /** * @param browser The active WebdriverIO Browser object. * @param blockType The type of the block to search for in the workspace. From 51bfadba11966fc98d575fce404f4703fe5e1c32 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Fri, 27 Jun 2025 09:41:41 -0700 Subject: [PATCH 33/67] Remove .only --- tests/browser/test/procedure_test.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/browser/test/procedure_test.mjs b/tests/browser/test/procedure_test.mjs index c31ef6d736b..ed63beb82de 100644 --- a/tests/browser/test/procedure_test.mjs +++ b/tests/browser/test/procedure_test.mjs @@ -32,8 +32,7 @@ suite('Testing Connecting Blocks', function (done) { this.browser.on('dialog', (dialog) => {}); }); - test.only('Testing Procedure', async function () { - + test('Testing Procedure', async function () { // Drag out first function let proceduresDefReturn = await getDraggableBlockElementByType( this.browser, From 77543d3c188793613088b99b6d5aed31827657b1 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Fri, 27 Jun 2025 11:44:09 -0700 Subject: [PATCH 34/67] Fix tests for opening categories --- tests/browser/test/test_setup.mjs | 1 + tests/browser/test/toolbox_drag_test.mjs | 37 +++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index edfad4d1b4c..040ca3df0dc 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -62,6 +62,7 @@ export async function driverSetup() { // Use Selenium to bring up the page console.log('Starting webdriverio...'); driver = await webdriverio.remote(options); + driver.setWindowSize(800, 600); return driver; } diff --git a/tests/browser/test/toolbox_drag_test.mjs b/tests/browser/test/toolbox_drag_test.mjs index 742872d9339..1845336eb07 100644 --- a/tests/browser/test/toolbox_drag_test.mjs +++ b/tests/browser/test/toolbox_drag_test.mjs @@ -11,6 +11,7 @@ import * as chai from 'chai'; import { getCategory, + getDraggableBlockElementByType, PAUSE_TIME, screenDirection, scrollFlyout, @@ -81,6 +82,32 @@ async function elementInBounds(browser, element) { }, element); } +/** + * Get the type of the nth block in the specified category. + * @param browser The active WebdriverIO Browser object. + * @param categoryName The name of the category to inspect. + * @param n The index of the block to get + * @returns A Promise resolving to the type the block in the specified + * category's flyout at index i. + */ +async function getNthBlockType(browser, categoryName, n) { + const category = await getCategory(browser, categoryName); + await category.click(); + await browser.pause(PAUSE_TIME); + + const blockType = await browser.execute((i) => { + return Blockly.getMainWorkspace() + .getFlyout() + .getWorkspace() + .getTopBlocks(false)[i].type; + }, n); + + // Unicode escape to close flyout. + await browser.keys(['\uE00C']); + await browser.pause(PAUSE_TIME); + return blockType; +} + /** * Get how many top-level blocks there are in the specified category. * @param browser The active WebdriverIO Browser object. @@ -145,14 +172,16 @@ async function openCategories(browser, categoryList, directionMultiplier) { await browser.pause(PAUSE_TIME); continue; } - const flyoutBlock = await browser.$( - `.blocklyFlyout .blocklyBlockCanvas > g:nth-child(${3 + i * 2})`, + const blockType = await getNthBlockType(browser, categoryName, i); + const flyoutBlock = await getDraggableBlockElementByType( + browser, + categoryName, + blockType, ); while (!(await elementInBounds(browser, flyoutBlock))) { await scrollFlyout(browser, 0, 50); } - - await flyoutBlock.dragAndDrop({x: directionMultiplier * 50, y: 0}); + await flyoutBlock.click(); await browser.pause(PAUSE_TIME); // Should be one top level block on the workspace. const topBlockCount = await browser.execute(() => { From 3d6ac549a9d82efdd99a152fc4ca2d35eaab6dd7 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Fri, 27 Jun 2025 14:55:31 -0700 Subject: [PATCH 35/67] Fix procedure tests --- tests/browser/test/procedure_test.mjs | 46 +++++++++++------------- tests/browser/test/test_setup.mjs | 36 +++++++++++++++++-- tests/browser/test/toolbox_drag_test.mjs | 38 ++------------------ 3 files changed, 57 insertions(+), 63 deletions(-) diff --git a/tests/browser/test/procedure_test.mjs b/tests/browser/test/procedure_test.mjs index ed63beb82de..0b0616acd8e 100644 --- a/tests/browser/test/procedure_test.mjs +++ b/tests/browser/test/procedure_test.mjs @@ -11,10 +11,8 @@ import * as chai from 'chai'; import { connect, - getBlockTypeFromCategory, - getDraggableBlockElementByType, - getNthBlockOfCategory, - getSelectedBlockElement, + dragBlockTypeFromFlyout, + dragNthBlockFromFlyout, PAUSE_TIME, testFileLocations, testSetup, @@ -34,43 +32,41 @@ suite('Testing Connecting Blocks', function (done) { test('Testing Procedure', async function () { // Drag out first function - let proceduresDefReturn = await getDraggableBlockElementByType( + const doSomething = await dragBlockTypeFromFlyout( this.browser, 'Functions', 'procedures_defreturn', + 50, + 20, ); - await proceduresDefReturn.dragAndDrop({x: 50, y: 20}); - const doSomething = await getSelectedBlockElement(this.browser); - // Drag out second function. - proceduresDefReturn = await getDraggableBlockElementByType( + const doSomething2 = await dragBlockTypeFromFlyout( this.browser, 'Functions', 'procedures_defreturn', + 50, + 20, ); - await proceduresDefReturn.dragAndDrop({x: 50, y: 20}); - const doSomething2 = await getSelectedBlockElement(this.browser); - // Drag out numeric - const mathNumeric = await getBlockTypeFromCategory( + const numeric = await dragBlockTypeFromFlyout( this.browser, 'Math', 'math_number', + 50, + 20, ); - await mathNumeric.dragAndDrop({x: 50, y: 20}); - const numeric = await getSelectedBlockElement(this.browser); // Connect numeric to first procedure await connect(this.browser, numeric, 'OUTPUT', doSomething, 'RETURN'); // Drag out doSomething caller from flyout. - const doSomethingFlyout = await getNthBlockOfCategory( + const doSomethingCaller = await dragNthBlockFromFlyout( this.browser, 'Functions', 3, + 50, + 20, ); - await doSomethingFlyout.dragAndDrop({x: 50, y: 20}); - const doSomethingCaller = await getSelectedBlockElement(this.browser); // Connect the doSomething caller to doSomething2 await connect( @@ -82,22 +78,22 @@ suite('Testing Connecting Blocks', function (done) { ); // Drag out print from flyout. - const printFlyout = await getBlockTypeFromCategory( + const print = await dragBlockTypeFromFlyout( this.browser, 'Text', 'text_print', + 50, + 0, ); - await printFlyout.dragAndDrop({x: 50, y: 0}); - const print = await getSelectedBlockElement(this.browser); // Drag out doSomething2 caller from flyout. - const doSomething2Flyout = await getNthBlockOfCategory( + const doSomething2Caller = await dragNthBlockFromFlyout( this.browser, 'Functions', 4, + 50, + 20, ); - await doSomething2Flyout.dragAndDrop({x: 50, y: 20}); - const doSomething2Caller = await getSelectedBlockElement(this.browser); // Connect doSomething2 caller with print. await connect(this.browser, doSomething2Caller, 'OUTPUT', print, 'TEXT'); @@ -107,7 +103,7 @@ suite('Testing Connecting Blocks', function (done) { runButton.click(); await this.browser.pause(PAUSE_TIME); const alertText = await this.browser.getAlertText(); // get the alert text - chai.assert.equal(alertText, '123'); + chai.assert.equal(alertText, 'abc'); await this.browser.acceptAlert(); }); }); diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index 040ca3df0dc..2d2525a4873 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -216,7 +216,7 @@ export async function clickBlock(browser, blockId, clickOptions) { * @return A Promise that resolves when the actions are completed. */ export async function clickWorkspace(browser) { - const workspace = await browser.$('#blocklyDiv > div > svg.blocklySvg > g'); + const workspace = await browser.$('svg.blocklySvg > g'); await workspace.click(); await browser.pause(PAUSE_TIME); } @@ -499,6 +499,9 @@ export async function switchRTL(browser) { */ export async function dragNthBlockFromFlyout(browser, categoryName, n, x, y) { const flyoutBlock = await getNthBlockOfCategory(browser, categoryName, n); + while (!(await elementInBounds(browser, flyoutBlock))) { + await scrollFlyout(browser, 0, 50); + } await flyoutBlock.dragAndDrop({x: x, y: y}); return await getSelectedBlockElement(browser); } @@ -525,15 +528,44 @@ export async function dragBlockTypeFromFlyout( x, y, ) { - const flyoutBlock = await getBlockTypeFromCategory( + const flyoutBlock = await getDraggableBlockElementByType( browser, categoryName, type, ); + while (!(await elementInBounds(browser, flyoutBlock))) { + await scrollFlyout(browser, 0, 50); + } await flyoutBlock.dragAndDrop({x: x, y: y}); + await browser.pause(PAUSE_TIME); return await getSelectedBlockElement(browser); } +/** + * Check whether an element is fully inside the bounds of the Blockly div. You can use this + * to determine whether a block on the workspace or flyout is inside the Blockly div. + * This does not check whether there are other Blockly elements (such as a toolbox or + * flyout) on top of the element. A partially visible block is considered out of bounds. + * @param browser The active WebdriverIO Browser object. + * @param element The element to look for. + * @returns A Promise resolving to true if the element is in bounds and false otherwise. + */ +async function elementInBounds(browser, element) { + return await browser.execute((elem) => { + const rect = elem.getBoundingClientRect(); + + const blocklyDiv = document.getElementsByClassName('blocklySvg')[0]; + const blocklyRect = blocklyDiv.getBoundingClientRect(); + + const vertInView = + rect.top >= blocklyRect.top && rect.bottom <= blocklyRect.bottom; + const horInView = + rect.left >= blocklyRect.left && rect.right <= blocklyRect.right; + + return vertInView && horInView; + }, element); +} + /** * Drags the specified block type from the mutator flyout of the given block * and returns the root element of the block. diff --git a/tests/browser/test/toolbox_drag_test.mjs b/tests/browser/test/toolbox_drag_test.mjs index 1845336eb07..801f5ad7873 100644 --- a/tests/browser/test/toolbox_drag_test.mjs +++ b/tests/browser/test/toolbox_drag_test.mjs @@ -10,11 +10,10 @@ import * as chai from 'chai'; import { + dragBlockTypeFromFlyout, getCategory, - getDraggableBlockElementByType, PAUSE_TIME, screenDirection, - scrollFlyout, testFileLocations, testSetup, } from './test_setup.mjs'; @@ -57,31 +56,6 @@ const testCategories = [ 'Serialization', ]; -/** - * Check whether an element is fully inside the bounds of the Blockly div. You can use this - * to determine whether a block on the workspace or flyout is inside the Blockly div. - * This does not check whether there are other Blockly elements (such as a toolbox or - * flyout) on top of the element. A partially visible block is considered out of bounds. - * @param browser The active WebdriverIO Browser object. - * @param element The element to look for. - * @returns A Promise resolving to true if the element is in bounds and false otherwise. - */ -async function elementInBounds(browser, element) { - return await browser.execute((elem) => { - const rect = elem.getBoundingClientRect(); - - const blocklyDiv = document.getElementById('blocklyDiv'); - const blocklyRect = blocklyDiv.getBoundingClientRect(); - - const vertInView = - rect.top >= blocklyRect.top && rect.bottom <= blocklyRect.bottom; - const horInView = - rect.left >= blocklyRect.left && rect.right <= blocklyRect.right; - - return vertInView && horInView; - }, element); -} - /** * Get the type of the nth block in the specified category. * @param browser The active WebdriverIO Browser object. @@ -173,15 +147,7 @@ async function openCategories(browser, categoryList, directionMultiplier) { continue; } const blockType = await getNthBlockType(browser, categoryName, i); - const flyoutBlock = await getDraggableBlockElementByType( - browser, - categoryName, - blockType, - ); - while (!(await elementInBounds(browser, flyoutBlock))) { - await scrollFlyout(browser, 0, 50); - } - await flyoutBlock.click(); + dragBlockTypeFromFlyout(browser, categoryName, blockType, 50, 20); await browser.pause(PAUSE_TIME); // Should be one top level block on the workspace. const topBlockCount = await browser.execute(() => { From ce3e2514413f58cb468aae1cc4495ff9d3a1832f Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Fri, 27 Jun 2025 15:24:09 -0700 Subject: [PATCH 36/67] Disable test to drag all blocks out and fix comment resize test --- tests/browser/test/procedure_test.mjs | 2 +- tests/browser/test/test_setup.mjs | 1 + tests/browser/test/toolbox_drag_test.mjs | 4 +++- tests/browser/test/workspace_comment_test.mjs | 14 +++++++------- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/browser/test/procedure_test.mjs b/tests/browser/test/procedure_test.mjs index 0b0616acd8e..d1990fddc4a 100644 --- a/tests/browser/test/procedure_test.mjs +++ b/tests/browser/test/procedure_test.mjs @@ -103,7 +103,7 @@ suite('Testing Connecting Blocks', function (done) { runButton.click(); await this.browser.pause(PAUSE_TIME); const alertText = await this.browser.getAlertText(); // get the alert text - chai.assert.equal(alertText, 'abc'); + chai.assert.equal(alertText, '123'); await this.browser.acceptAlert(); }); }); diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index 2d2525a4873..edbafae4215 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -63,6 +63,7 @@ export async function driverSetup() { console.log('Starting webdriverio...'); driver = await webdriverio.remote(options); driver.setWindowSize(800, 600); + driver.setViewport({width: 800, height: 600}); return driver; } diff --git a/tests/browser/test/toolbox_drag_test.mjs b/tests/browser/test/toolbox_drag_test.mjs index 801f5ad7873..22d1bb16579 100644 --- a/tests/browser/test/toolbox_drag_test.mjs +++ b/tests/browser/test/toolbox_drag_test.mjs @@ -173,7 +173,9 @@ async function openCategories(browser, categoryList, directionMultiplier) { chai.assert.equal(failureCount, 0); } -suite('Open toolbox categories', function () { +// These take too long to run and are very flakey. Need to find a better way to +// test whatever this is trying to test. +suite.skip('Open toolbox categories', function () { this.timeout(0); test('opening every toolbox category in the category toolbox in LTR', async function () { diff --git a/tests/browser/test/workspace_comment_test.mjs b/tests/browser/test/workspace_comment_test.mjs index 516523276f7..db42f30991a 100644 --- a/tests/browser/test/workspace_comment_test.mjs +++ b/tests/browser/test/workspace_comment_test.mjs @@ -206,13 +206,13 @@ suite('Workspace comments', function () { '.blocklyComment .blocklyResizeHandle', ); await resizeHandle.dragAndDrop(delta); - - chai.assert.deepEqual( - await getCommentSize(this.browser, commentId), - { - width: origSize.width + delta.x, - height: origSize.height + delta.y, - }, + const newSize = await getCommentSize(this.browser, commentId); + chai.assert.isTrue( + Math.abs(newSize.width - (origSize.width + delta.x)) < 1, + 'Expected the comment model size to match the resized size', + ); + chai.assert.isTrue( + Math.abs(newSize.height - (origSize.height + delta.y)) < 1, 'Expected the comment model size to match the resized size', ); }); From 53b6362c2fe19463bb36e755b90350d6854530a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 08:26:04 -0700 Subject: [PATCH 37/67] chore(deps): bump eslint from 9.26.0 to 9.30.0 (#9186) Bumps [eslint](https://github.com/eslint/eslint) from 9.26.0 to 9.30.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v9.26.0...v9.30.0) --- updated-dependencies: - dependency-name: eslint dependency-version: 9.30.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 716 ++++------------------------------------------ 1 file changed, 60 insertions(+), 656 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f8de5349ba..779dc904fd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -428,11 +428,10 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -443,21 +442,19 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -503,13 +500,15 @@ } }, "node_modules/@eslint/js": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", - "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", + "version": "9.30.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz", + "integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -517,25 +516,35 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@gulp-sourcemaps/identity-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", @@ -1144,28 +1153,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.3", - "eventsource": "^3.0.2", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2029,24 +2016,10 @@ "node": ">=6.5" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -2831,27 +2804,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3365,40 +3317,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -3417,26 +3335,6 @@ "safe-buffer": "~5.1.1" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/copy-props": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-4.0.0.tgz", @@ -3456,20 +3354,6 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/corser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", @@ -3803,16 +3687,6 @@ "node": ">= 14" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4008,29 +3882,12 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/encoding-sniffer": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", @@ -4162,13 +4019,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4215,24 +4065,22 @@ } }, "node_modules/eslint": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", - "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", + "version": "9.30.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz", + "integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.26.0", - "@eslint/plugin-kit": "^0.2.8", + "@eslint/js": "9.30.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", - "@modelcontextprotocol/sdk": "^1.8.0", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -4240,9 +4088,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4256,8 +4104,7 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "zod": "^3.24.2" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -4382,11 +4229,10 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -4411,9 +4257,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4435,14 +4281,14 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4452,9 +4298,9 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4493,7 +4339,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -4519,16 +4364,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -4563,29 +4398,6 @@ "node": ">=0.8.x" } }, - "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -4598,65 +4410,6 @@ "node": ">=0.10.0" } }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" - } - }, "node_modules/ext": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", @@ -4870,24 +4623,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5051,26 +4786,6 @@ "node": ">=12.20.0" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -6085,23 +5800,6 @@ "entities": "^4.5.0" } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/http-proxy": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", @@ -6311,16 +6009,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -7119,16 +6807,6 @@ "node": ">= 0.4" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/memoizee": { "version": "0.4.15", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", @@ -7151,19 +6829,6 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7200,29 +6865,6 @@ "node": ">=4" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7464,16 +7106,6 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -7631,19 +7263,6 @@ "node": ">=0.10.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7880,16 +7499,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/patch-package": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", @@ -8106,16 +7715,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/plugin-error": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", @@ -8279,20 +7878,6 @@ "node": ">=0.4.0" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -8407,32 +7992,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8778,40 +8337,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -8922,29 +8447,6 @@ "node": ">= 10.13.0" } }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/serialize-error": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-11.0.3.tgz", @@ -8969,35 +8471,12 @@ "randombytes": "^2.1.0" } }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "dev": true }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9257,16 +8736,6 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/stream-composer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", @@ -9661,16 +9130,6 @@ "node": ">=10.13.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -9762,21 +9221,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -9899,16 +9343,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -9973,16 +9407,6 @@ "node": ">= 10.13.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vinyl": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", @@ -10610,26 +10034,6 @@ "dependencies": { "safe-buffer": "~5.2.0" } - }, - "node_modules/zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } } } } From 6a04d0eadbf031a8ce689a81d7910adf1ae68d7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:19:06 -0700 Subject: [PATCH 38/67] chore(deps): bump eslint-plugin-jsdoc from 50.7.1 to 51.3.1 (#9191) Bumps [eslint-plugin-jsdoc](https://github.com/gajus/eslint-plugin-jsdoc) from 50.7.1 to 51.3.1. - [Release notes](https://github.com/gajus/eslint-plugin-jsdoc/releases) - [Changelog](https://github.com/gajus/eslint-plugin-jsdoc/blob/main/.releaserc) - [Commits](https://github.com/gajus/eslint-plugin-jsdoc/compare/v50.7.1...v51.3.1) --- updated-dependencies: - dependency-name: eslint-plugin-jsdoc dependency-version: 51.3.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 49 ++++++++++++++++++++++++++++------------------- package.json | 2 +- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 779dc904fd7..8696a841805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "eslint": "^9.15.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.1", - "eslint-plugin-jsdoc": "^50.5.0", + "eslint-plugin-jsdoc": "^51.3.1", "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", @@ -383,20 +383,32 @@ } }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.50.2", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz", - "integrity": "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==", + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.52.0.tgz", + "integrity": "sha512-BXuN7BII+8AyNtn57euU2Yxo9yA/KUDNzrpXyi3pfqKmBhhysR6ZWOebFh3vyPoqA3/j1SOvGgucElMGwlXing==", "dev": true, - "license": "MIT", "dependencies": { - "@types/estree": "^1.0.6", - "@typescript-eslint/types": "^8.11.0", + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.34.1", "comment-parser": "1.4.1", "esquery": "^1.6.0", "jsdoc-type-pratt-parser": "~4.1.0" }, "engines": { - "node": ">=18" + "node": ">=20.11.0" + } + }, + "node_modules/@es-joy/jsdoccomment/node_modules/@typescript-eslint/types": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1466,9 +1478,9 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, "node_modules/@types/expect": { @@ -3133,7 +3145,6 @@ "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 12.0.0" } @@ -4150,25 +4161,24 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "50.7.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.7.1.tgz", - "integrity": "sha512-XBnVA5g2kUVokTNUiE1McEPse5n9/mNUmuJcx52psT6zBs2eVcXSmQBvjfa7NZdfLVSy3u1pEDDUxoxpwy89WA==", + "version": "51.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-51.3.1.tgz", + "integrity": "sha512-9v/e6XyrLf1HIs/uPCgm3GcUpH4BeuGVZJk7oauKKyS7su7d5Q6zx4Fq6TiYh+w7+b4Svy7ZWVCcNZJNx3y52w==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.50.2", + "@es-joy/jsdoccomment": "~0.52.0", "are-docs-informative": "^0.0.2", "comment-parser": "1.4.1", "debug": "^4.4.1", "escape-string-regexp": "^4.0.0", - "espree": "^10.3.0", + "espree": "^10.4.0", "esquery": "^1.6.0", "parse-imports-exports": "^0.2.4", "semver": "^7.7.2", "spdx-expression-parse": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=20.11.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" @@ -6314,7 +6324,6 @@ "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12.0.0" } diff --git a/package.json b/package.json index eab39b16cde..c4e83340dcc 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "eslint": "^9.15.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.1", - "eslint-plugin-jsdoc": "^50.5.0", + "eslint-plugin-jsdoc": "^51.3.1", "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", From 9424deb06ab7cccfdc6e750daa72bf4b8af7f9aa Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Mon, 30 Jun 2025 09:32:08 -0700 Subject: [PATCH 39/67] build: Refactor gulpfiles from CJS to ESM (#9149) * refactor(build): Rename "package" gulp task (but not npm script) to "pack" This is to avoid an issue due to "package" being a reserved word in JavaScript, and therefore not a valid export identifier. * refactor(build): Convert gulpfile.js from CJS to ESM. * refactor(build): Convert scripts/gulpfiles/*.js from CJS to ESM * fix(build): Fix eslint warning for @license tag in gulpfile.mjs * chore(build): Remove unused imports * fix(build): Fix incorrect import of gulp-gzip * fix(build): Fix incorrect sourcemaps import reference --- eslint.config.mjs | 2 +- gulpfile.js | 54 ----------- gulpfile.mjs | 95 +++++++++++++++++++ package.json | 2 +- ...appengine_tasks.js => appengine_tasks.mjs} | 31 +++--- .../{build_tasks.js => build_tasks.mjs} | 84 ++++++++-------- scripts/gulpfiles/{config.js => config.mjs} | 14 +-- .../{docs_tasks.js => docs_tasks.mjs} | 15 ++- .../gulpfiles/{git_tasks.js => git_tasks.mjs} | 34 ++----- scripts/gulpfiles/helper_tasks.js | 19 ---- scripts/gulpfiles/helper_tasks.mjs | 25 +++++ .../{package_tasks.js => package_tasks.mjs} | 47 ++++----- .../{release_tasks.js => release_tasks.mjs} | 37 +++----- .../{test_tasks.js => test_tasks.mjs} | 30 +++--- 14 files changed, 251 insertions(+), 238 deletions(-) delete mode 100644 gulpfile.js create mode 100644 gulpfile.mjs rename scripts/gulpfiles/{appengine_tasks.js => appengine_tasks.mjs} (86%) rename scripts/gulpfiles/{build_tasks.js => build_tasks.mjs} (92%) rename scripts/gulpfiles/{config.js => config.mjs} (70%) rename scripts/gulpfiles/{docs_tasks.js => docs_tasks.mjs} (94%) rename scripts/gulpfiles/{git_tasks.js => git_tasks.mjs} (86%) delete mode 100644 scripts/gulpfiles/helper_tasks.js create mode 100644 scripts/gulpfiles/helper_tasks.mjs rename scripts/gulpfiles/{package_tasks.js => package_tasks.mjs} (89%) rename scripts/gulpfiles/{release_tasks.js => release_tasks.mjs} (87%) rename scripts/gulpfiles/{test_tasks.js => test_tasks.mjs} (94%) diff --git a/eslint.config.mjs b/eslint.config.mjs index 68f25133fa5..f018e525d87 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -184,7 +184,7 @@ export default [ files: [ 'eslint.config.mjs', '.prettierrc.js', - 'gulpfile.js', + 'gulpfile.mjs', 'scripts/helpers.js', 'tests/mocha/.mocharc.js', 'tests/migration/validate-renamings.mjs', diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index d2ad650c64a..00000000000 --- a/gulpfile.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2018 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Gulp script to build Blockly for Node & NPM. - * Run this script by calling "npm install" in this directory. - */ -/* eslint-env node */ - -const gulp = require('gulp'); - -const buildTasks = require('./scripts/gulpfiles/build_tasks'); -const packageTasks = require('./scripts/gulpfiles/package_tasks'); -const gitTasks = require('./scripts/gulpfiles/git_tasks'); -const appengineTasks = require('./scripts/gulpfiles/appengine_tasks'); -const releaseTasks = require('./scripts/gulpfiles/release_tasks'); -const docsTasks = require('./scripts/gulpfiles/docs_tasks'); -const testTasks = require('./scripts/gulpfiles/test_tasks'); - -module.exports = { - // Default target if gulp invoked without specifying. - default: buildTasks.build, - - // Main sequence targets. They already invoke prerequisites. - langfiles: buildTasks.langfiles, // Build build/msg/*.js from msg/json/*. - tsc: buildTasks.tsc, - deps: buildTasks.deps, - minify: buildTasks.minify, - build: buildTasks.build, - package: packageTasks.package, - publish: releaseTasks.publish, - publishBeta: releaseTasks.publishBeta, - prepareDemos: appengineTasks.prepareDemos, - deployDemos: appengineTasks.deployDemos, - deployDemosBeta: appengineTasks.deployDemosBeta, - gitUpdateGithubPages: gitTasks.updateGithubPages, - - // Manually-invokable targets, with prerequisites where required. - messages: buildTasks.messages, // Generate msg/json/en.json et al. - clean: gulp.parallel(buildTasks.cleanBuildDir, packageTasks.cleanReleaseDir), - test: testTasks.test, - testGenerators: testTasks.generators, - buildAdvancedCompilationTest: buildTasks.buildAdvancedCompilationTest, - gitCreateRC: gitTasks.createRC, - docs: docsTasks.docs, - - // Legacy targets, to be deleted. - recompile: releaseTasks.recompile, - gitSyncDevelop: gitTasks.syncDevelop, - gitSyncMaster: gitTasks.syncMaster, -}; diff --git a/gulpfile.mjs b/gulpfile.mjs new file mode 100644 index 00000000000..fd3de3bde8c --- /dev/null +++ b/gulpfile.mjs @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Gulp script to build Blockly for Node & NPM. + * Run this script by calling "npm install" in this directory. + */ +/* eslint-env node */ + +// Needed to prevent prettier from munging exports order, due to +// https://github.com/simonhaenisch/prettier-plugin-organize-imports/issues/146 +// - but has the unfortunate side effect of suppressing ordering of +// imports too: +// +// organize-imports-ignore + +import {parallel} from 'gulp'; +import { + deployDemos, + deployDemosBeta, + prepareDemos, +} from './scripts/gulpfiles/appengine_tasks.mjs'; +import { + build, + buildAdvancedCompilationTest, + cleanBuildDir, + langfiles, + messages, + minify, + tsc, +} from './scripts/gulpfiles/build_tasks.mjs'; +import {docs} from './scripts/gulpfiles/docs_tasks.mjs'; +import { + createRC, + syncDevelop, + syncMaster, + updateGithubPages, +} from './scripts/gulpfiles/git_tasks.mjs'; +import {cleanReleaseDir, pack} from './scripts/gulpfiles/package_tasks.mjs'; +import { + publish, + publishBeta, + recompile, +} from './scripts/gulpfiles/release_tasks.mjs'; +import {generators, test} from './scripts/gulpfiles/test_tasks.mjs'; + +const clean = parallel(cleanBuildDir, cleanReleaseDir); + +// Default target if gulp invoked without specifying. +export default build; + +// Main sequence targets. They already invoke prerequisites. Listed +// in typical order of invocation, and strictly listing prerequisites +// before dependants. +// +// prettier-ignore +export { + langfiles, + tsc, + minify, + build, + pack, // Formerly package. + publishBeta, + publish, + prepareDemos, + deployDemosBeta, + deployDemos, + updateGithubPages as gitUpdateGithubPages, +} + +// Manually-invokable targets that also invoke prerequisites where +// required. +// +// prettier-ignore +export { + messages, // Generate msg/json/en.json et al. + clean, + test, + generators as testGenerators, + buildAdvancedCompilationTest, + createRC as gitCreateRC, + docs, +} + +// Legacy targets, to be deleted. +// +// prettier-ignore +export { + recompile, + syncDevelop as gitSyncDevelop, + syncMaster as gitSyncMaster, +} diff --git a/package.json b/package.json index c4e83340dcc..6ed5e4ea4cc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "lint:fix": "eslint . --fix", "langfiles": "gulp langfiles", "minify": "gulp minify", - "package": "gulp package", + "package": "gulp pack", "postinstall": "patch-package", "prepareDemos": "gulp prepareDemos", "publish": "npm ci && gulp publish", diff --git a/scripts/gulpfiles/appengine_tasks.js b/scripts/gulpfiles/appengine_tasks.mjs similarity index 86% rename from scripts/gulpfiles/appengine_tasks.js rename to scripts/gulpfiles/appengine_tasks.mjs index ddbd2f45f90..7545343832f 100644 --- a/scripts/gulpfiles/appengine_tasks.js +++ b/scripts/gulpfiles/appengine_tasks.mjs @@ -8,16 +8,16 @@ * @fileoverview Gulp script to deploy Blockly demos on appengine. */ -const gulp = require('gulp'); +import * as gulp from 'gulp'; -const fs = require('fs'); -const path = require('path'); -const execSync = require('child_process').execSync; -const buildTasks = require('./build_tasks.js'); -const packageTasks = require('./package_tasks.js'); -const {rimraf} = require('rimraf'); +import * as fs from 'fs'; +import * as path from 'path'; +import {execSync} from 'child_process'; +import * as buildTasks from './build_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; +import {rimraf} from 'rimraf'; -const packageJson = require('../../package.json'); const demoTmpDir = '../_deploy'; const demoStaticTmpDir = '../_deploy/static'; @@ -123,7 +123,7 @@ function deployToAndClean(demoVersion) { */ function getDemosVersion() { // Replace all '.' with '-' e.g. 9-3-3-beta-2 - return packageJson.version.replace(/\./g, '-'); + return getPackageJson().version.replace(/\./g, '-'); } /** @@ -162,7 +162,7 @@ function deployBetaAndClean(done) { * * Prerequisites (invoked): clean, build */ -const prepareDemos = gulp.series( +export const prepareDemos = gulp.series( prepareDeployDir, gulp.parallel( gulp.series( @@ -180,16 +180,9 @@ const prepareDemos = gulp.series( /** * Deploys demos. */ -const deployDemos = gulp.series(prepareDemos, deployAndClean); +export const deployDemos = gulp.series(prepareDemos, deployAndClean); /** * Deploys beta version of demos (version appended with -beta). */ -const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - deployDemos: deployDemos, - deployDemosBeta: deployDemosBeta, - prepareDemos: prepareDemos -}; +export const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean); diff --git a/scripts/gulpfiles/build_tasks.js b/scripts/gulpfiles/build_tasks.mjs similarity index 92% rename from scripts/gulpfiles/build_tasks.js rename to scripts/gulpfiles/build_tasks.mjs index a00c1b17dc3..669e732588d 100644 --- a/scripts/gulpfiles/build_tasks.js +++ b/scripts/gulpfiles/build_tasks.mjs @@ -8,25 +8,32 @@ * @fileoverview Gulp script to build Blockly for Node & NPM. */ -const gulp = require('gulp'); -gulp.replace = require('gulp-replace'); -gulp.rename = require('gulp-rename'); -gulp.sourcemaps = require('gulp-sourcemaps'); +import * as gulp from 'gulp'; +import replace from 'gulp-replace'; +import rename from 'gulp-rename'; +import sourcemaps from 'gulp-sourcemaps'; -const path = require('path'); -const fs = require('fs'); -const fsPromises = require('fs/promises'); -const {exec, execSync} = require('child_process'); +import * as path from 'path'; +import * as fs from 'fs'; +import * as fsPromises from 'fs/promises'; +import {exec, execSync} from 'child_process'; -const {globSync} = require('glob'); -const closureCompiler = require('google-closure-compiler').gulp(); -const argv = require('yargs').argv; -const {rimraf} = require('rimraf'); +import {globSync} from 'glob'; +// For v20250609.0.0 and later: +// import {gulp as closureCompiler} from 'google-closure-compiler'; +import ClosureCompiler from 'google-closure-compiler'; +import yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; +import {rimraf} from 'rimraf'; -const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} = require('./config'); -const {getPackageJson} = require('./helper_tasks'); +import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} from './config.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; -const {posixPath, quote} = require('../helpers'); +import {posixPath, quote} from '../helpers.js'; + +const closureCompiler = ClosureCompiler.gulp(); + +const argv = yargs(hideBin(process.argv)).parse(); //////////////////////////////////////////////////////////// // Build // @@ -182,7 +189,7 @@ function stripApacheLicense() { // Closure Compiler preserves dozens of Apache licences in the Blockly code. // Remove these if they belong to Google or MIT. // MIT's permission to do this is logged in Blockly issue #2412. - return gulp.replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n'); + return replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n'); // Replace with the same number of lines so that source-maps are not affected. } @@ -306,7 +313,7 @@ const JSCOMP_OFF = [ * Builds Blockly as a JS program, by running tsc on all the files in * the core directory. */ -function buildJavaScript(done) { +export function tsc(done) { execSync( `tsc -outDir "${TSC_OUTPUT_DIR}" -declarationDir "${TYPINGS_BUILD_DIR}"`, {stdio: 'inherit'}); @@ -318,7 +325,7 @@ function buildJavaScript(done) { * This task regenerates msg/json/en.js and msg/json/qqq.js from * msg/messages.js. */ -function generateMessages(done) { +export function messages(done) { // Run js_to_json.py const jsToJsonCmd = `${PYTHON} scripts/i18n/js_to_json.py \ --input_file ${path.join('msg', 'messages.js')} \ @@ -573,10 +580,10 @@ function buildCompiled() { // Fire up compilation pipline. return gulp.src(chunkOptions.js, {base: './'}) .pipe(stripApacheLicense()) - .pipe(gulp.sourcemaps.init()) + .pipe(sourcemaps.init()) .pipe(compile(options)) - .pipe(gulp.rename({suffix: COMPILED_SUFFIX})) - .pipe(gulp.sourcemaps.write('.')) + .pipe(rename({suffix: COMPILED_SUFFIX})) + .pipe(sourcemaps.write('.')) .pipe(gulp.dest(RELEASE_DIR)); } @@ -668,7 +675,7 @@ async function buildLangfileShims() { // (We have to do it this way because messages.js is a script and // not a CJS module with exports.) globalThis.Blockly = {Msg: {}}; - require('../../msg/messages.js'); + await import('../../msg/messages.js'); const exportedNames = Object.keys(globalThis.Blockly.Msg); delete globalThis.Blockly; @@ -689,12 +696,14 @@ ${exportedNames.map((name) => ` ${name},`).join('\n')} } /** - * This task builds Blockly core, blocks and generators together and uses - * Closure Compiler's ADVANCED_COMPILATION mode. + * This task uses Closure Compiler's ADVANCED_COMPILATION mode to + * compile together Blockly core, blocks and generators with a simple + * test app; the purpose is to verify that Blockly is compatible with + * the ADVANCED_COMPILATION mode. * * Prerequisite: buildJavaScript. */ -function buildAdvancedCompilationTest() { +function compileAdvancedCompilationTest() { // If main_compressed.js exists (from a previous run) delete it so that // a later browser-based test won't check it should the compile fail. try { @@ -718,9 +727,9 @@ function buildAdvancedCompilationTest() { }; return gulp.src(srcs, {base: './'}) .pipe(stripApacheLicense()) - .pipe(gulp.sourcemaps.init()) + .pipe(sourcemaps.init()) .pipe(compile(options)) - .pipe(gulp.sourcemaps.write( + .pipe(sourcemaps.write( '.', {includeContent: false, sourceRoot: '../../'})) .pipe(gulp.dest('./tests/compile/')); } @@ -728,7 +737,7 @@ function buildAdvancedCompilationTest() { /** * This task cleans the build directory (by deleting it). */ -function cleanBuildDir() { +export function cleanBuildDir() { // Sanity check. if (BUILD_DIR === '.' || BUILD_DIR === '/') { return Promise.reject(`Refusing to rm -rf ${BUILD_DIR}`); @@ -737,16 +746,13 @@ function cleanBuildDir() { } // Main sequence targets. Each should invoke any immediate prerequisite(s). -exports.cleanBuildDir = cleanBuildDir; -exports.langfiles = gulp.parallel(buildLangfiles, buildLangfileShims); -exports.tsc = buildJavaScript; -exports.minify = gulp.series(exports.tsc, buildCompiled, buildShims); -exports.build = gulp.parallel(exports.minify, exports.langfiles); +// function cleanBuildDir, above +export const langfiles = gulp.parallel(buildLangfiles, buildLangfileShims); +export const minify = gulp.series(tsc, buildCompiled, buildShims); +// function tsc, above +export const build = gulp.parallel(minify, langfiles); // Manually-invokable targets, with prerequisites where required. -exports.messages = generateMessages; // Generate msg/json/en.json et al. -exports.buildAdvancedCompilationTest = - gulp.series(exports.tsc, buildAdvancedCompilationTest); - -// Targets intended only for invocation by scripts; may omit prerequisites. -exports.onlyBuildAdvancedCompilationTest = buildAdvancedCompilationTest; +// function messages, above +export const buildAdvancedCompilationTest = + gulp.series(tsc, compileAdvancedCompilationTest); diff --git a/scripts/gulpfiles/config.js b/scripts/gulpfiles/config.mjs similarity index 70% rename from scripts/gulpfiles/config.js rename to scripts/gulpfiles/config.mjs index 90cd571099d..52e4cd06fe1 100644 --- a/scripts/gulpfiles/config.js +++ b/scripts/gulpfiles/config.mjs @@ -8,7 +8,7 @@ * @fileoverview Common configuration for Gulp scripts. */ -const path = require('path'); +import * as path from 'path'; // Paths are all relative to the repository root. Do not include // trailing slash. @@ -21,21 +21,21 @@ const path = require('path'); // - tests/scripts/update_metadata.sh // Directory to write compiled output to. -exports.BUILD_DIR = 'build'; +export const BUILD_DIR = 'build'; // Directory to write typings output to. -exports.TYPINGS_BUILD_DIR = path.join(exports.BUILD_DIR, 'declarations'); +export const TYPINGS_BUILD_DIR = path.join(BUILD_DIR, 'declarations'); // Directory to write langfile output to. -exports.LANG_BUILD_DIR = path.join(exports.BUILD_DIR, 'msg'); +export const LANG_BUILD_DIR = path.join(BUILD_DIR, 'msg'); // Directory where typescript compiler output can be found. // Matches the value in tsconfig.json: outDir -exports.TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'src'); +export const TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'src'); // Directory for files generated by compiling test code. -exports.TEST_TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'tests'); +export const TEST_TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'tests'); // Directory in which to assemble (and from which to publish) the // blockly npm package. -exports.RELEASE_DIR = 'dist'; +export const RELEASE_DIR = 'dist'; diff --git a/scripts/gulpfiles/docs_tasks.js b/scripts/gulpfiles/docs_tasks.mjs similarity index 94% rename from scripts/gulpfiles/docs_tasks.js rename to scripts/gulpfiles/docs_tasks.mjs index 8820a586f9b..63fdbe66536 100644 --- a/scripts/gulpfiles/docs_tasks.js +++ b/scripts/gulpfiles/docs_tasks.mjs @@ -1,9 +1,9 @@ -const {execSync} = require('child_process'); -const {Extractor} = require('markdown-tables-to-json'); -const fs = require('fs'); -const gulp = require('gulp'); -const header = require('gulp-header'); -const replace = require('gulp-replace'); +import {execSync} from 'child_process'; +import {Extractor} from 'markdown-tables-to-json'; +import * as fs from 'fs'; +import * as gulp from 'gulp'; +import * as header from 'gulp-header'; +import * as replace from 'gulp-replace'; const DOCS_DIR = 'docs'; @@ -140,8 +140,7 @@ const createToc = function(done) { done(); } -const docs = gulp.series( +export const docs = gulp.series( generateApiJson, removeRenames, generateDocs, gulp.parallel(prependBook, createToc)); -module.exports = {docs}; diff --git a/scripts/gulpfiles/git_tasks.js b/scripts/gulpfiles/git_tasks.mjs similarity index 86% rename from scripts/gulpfiles/git_tasks.js rename to scripts/gulpfiles/git_tasks.mjs index 7c320cd8791..2b08e16b38b 100644 --- a/scripts/gulpfiles/git_tasks.js +++ b/scripts/gulpfiles/git_tasks.mjs @@ -8,11 +8,11 @@ * @fileoverview Git-related gulp tasks for Blockly. */ -const gulp = require('gulp'); -const execSync = require('child_process').execSync; +import * as gulp from 'gulp'; +import {execSync} from 'child_process'; -const buildTasks = require('./build_tasks'); -const packageTasks = require('./package_tasks'); +import * as buildTasks from './build_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; const UPSTREAM_URL = 'https://github.com/google/blockly.git'; @@ -63,7 +63,7 @@ function syncBranch(branchName) { * Stash current state, check out develop, and sync with * google/blockly. */ -function syncDevelop() { +export function syncDevelop() { return syncBranch('develop'); }; @@ -71,7 +71,7 @@ function syncDevelop() { * Stash current state, check out master, and sync with * google/blockly. */ -function syncMaster() { +export function syncMaster() { return syncBranch('master'); }; @@ -111,7 +111,7 @@ function checkoutBranch(branchName) { * Create and push an RC branch. * Note that this pushes to google/blockly. */ -const createRC = gulp.series( +export const createRC = gulp.series( syncDevelop(), function(done) { const branchName = getRCBranchName(); @@ -122,7 +122,7 @@ const createRC = gulp.series( ); /** Create the rebuild branch. */ -function createRebuildBranch(done) { +export function createRebuildBranch(done) { const branchName = getRebuildBranchName(); console.log(`make-rebuild-branch: creating branch ${branchName}`); execSync(`git switch -C ${branchName}`, { stdio: 'inherit' }); @@ -130,7 +130,7 @@ function createRebuildBranch(done) { } /** Push the rebuild branch to origin. */ -function pushRebuildBranch(done) { +export function pushRebuildBranch(done) { console.log('push-rebuild-branch: committing rebuild'); execSync('git commit -am "Rebuild"', { stdio: 'inherit' }); const branchName = getRebuildBranchName(); @@ -145,7 +145,7 @@ function pushRebuildBranch(done) { * * Prerequisites (invoked): clean, build. */ -const updateGithubPages = gulp.series( +export const updateGithubPages = gulp.series( function(done) { execSync('git stash save -m "Stash for sync"', { stdio: 'inherit' }); execSync('git switch -C gh-pages', { stdio: 'inherit' }); @@ -165,17 +165,3 @@ const updateGithubPages = gulp.series( done(); } ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - updateGithubPages, - - // Manually-invokable targets that invoke prerequisites. - createRC, - - // Legacy script-only targets, to be deleted. - syncDevelop, - syncMaster, - createRebuildBranch, - pushRebuildBranch, -}; diff --git a/scripts/gulpfiles/helper_tasks.js b/scripts/gulpfiles/helper_tasks.js deleted file mode 100644 index b239d03f5fa..00000000000 --- a/scripts/gulpfiles/helper_tasks.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Any gulp helper functions. - */ - -// Clears the require cache to ensure the package.json is up to date. -function getPackageJson() { - delete require.cache[require.resolve('../../package.json')] - return require('../../package.json'); -} - -module.exports = { - getPackageJson: getPackageJson -} diff --git a/scripts/gulpfiles/helper_tasks.mjs b/scripts/gulpfiles/helper_tasks.mjs new file mode 100644 index 00000000000..2068de106a5 --- /dev/null +++ b/scripts/gulpfiles/helper_tasks.mjs @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Any gulp helper functions. + */ + +import Module from "node:module"; + +const require = Module.createRequire(import.meta.url); + +/** + * Load and return the contents of package.json. + * + * Uses require() rather than import, and clears the require cache, to + * ensure the loaded package.json data is up to date. + */ +export function getPackageJson() { + delete require.cache[require.resolve('../../package.json')]; + return require('../../package.json'); +} + diff --git a/scripts/gulpfiles/package_tasks.js b/scripts/gulpfiles/package_tasks.mjs similarity index 89% rename from scripts/gulpfiles/package_tasks.js rename to scripts/gulpfiles/package_tasks.mjs index 89264a0e3c4..948f855b096 100644 --- a/scripts/gulpfiles/package_tasks.js +++ b/scripts/gulpfiles/package_tasks.mjs @@ -8,20 +8,17 @@ * @fileoverview Gulp tasks to package Blockly for distribution on NPM. */ -const gulp = require('gulp'); -gulp.concat = require('gulp-concat'); -gulp.replace = require('gulp-replace'); -gulp.rename = require('gulp-rename'); -gulp.insert = require('gulp-insert'); -gulp.umd = require('gulp-umd'); -gulp.replace = require('gulp-replace'); +import * as gulp from 'gulp'; +import concat from 'gulp-concat'; +import replace from 'gulp-replace'; +import umd from 'gulp-umd'; -const path = require('path'); -const fs = require('fs'); -const {rimraf} = require('rimraf'); -const build = require('./build_tasks'); -const {getPackageJson} = require('./helper_tasks'); -const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} = require('./config'); +import * as path from 'path'; +import * as fs from 'fs'; +import {rimraf} from 'rimraf'; +import * as build from './build_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} from './config.mjs'; // Path to template files for gulp-umd. const TEMPLATE_DIR = 'scripts/package/templates'; @@ -32,7 +29,7 @@ const TEMPLATE_DIR = 'scripts/package/templates'; * @param {Array} dependencies An array of dependencies to inject. */ function packageUMD(namespace, dependencies, template = 'umd.template') { - return gulp.umd({ + return umd({ dependencies: function () { return dependencies; }, namespace: function () { return namespace; }, exports: function () { return namespace; }, @@ -88,7 +85,7 @@ function packageCoreNode() { function packageLocales() { // Remove references to goog.provide and goog.require. return gulp.src(`${LANG_BUILD_DIR}/*.js`) - .pipe(gulp.replace(/goog\.[^\n]+/g, '')) + .pipe(replace(/goog\.[^\n]+/g, '')) .pipe(packageUMD('Blockly.Msg', [], 'umd-msg.template')) .pipe(gulp.dest(`${RELEASE_DIR}/msg`)); }; @@ -107,7 +104,7 @@ function packageUMDBundle() { `${RELEASE_DIR}/javascript_compressed.js`, ]; return gulp.src(srcs) - .pipe(gulp.concat('blockly.min.js')) + .pipe(concat('blockly.min.js')) .pipe(gulp.dest(`${RELEASE_DIR}`)); }; @@ -140,7 +137,7 @@ function packageUMDBundle() { * @param {Function} done Callback to call when done. */ function packageLegacyEntrypoints(done) { - for (entrypoint of [ + for (const entrypoint of [ 'core', 'blocks', 'dart', 'javascript', 'lua', 'php', 'python' ]) { const bundle = @@ -218,14 +215,14 @@ function packageDTS() { .pipe(gulp.src(`${TYPINGS_BUILD_DIR}/**/*.d.ts`, {ignore: [ `${TYPINGS_BUILD_DIR}/blocks/**/*`, ]})) - .pipe(gulp.replace('AnyDuringMigration', 'any')) + .pipe(replace('AnyDuringMigration', 'any')) .pipe(gulp.dest(RELEASE_DIR)); }; /** * This task cleans the release directory (by deleting it). */ -function cleanReleaseDir() { +export function cleanReleaseDir() { // Sanity check. if (RELEASE_DIR === '.' || RELEASE_DIR === '/') { return Promise.reject(`Refusing to rm -rf ${RELEASE_DIR}`); @@ -237,9 +234,13 @@ function cleanReleaseDir() { * This task prepares the files to be included in the NPM by copying * them into the release directory. * + * This task was formerly called "package" but was renamed in + * preparation for porting gulpfiles to ESM because "package" is a + * reserved word. + * * Prerequisite: build. */ -const package = gulp.series( +export const pack = gulp.series( gulp.parallel( build.cleanBuildDir, cleanReleaseDir), @@ -254,9 +255,3 @@ const package = gulp.series( packageReadme, packageDTS) ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - cleanReleaseDir: cleanReleaseDir, - package: package, -}; diff --git a/scripts/gulpfiles/release_tasks.js b/scripts/gulpfiles/release_tasks.mjs similarity index 87% rename from scripts/gulpfiles/release_tasks.js rename to scripts/gulpfiles/release_tasks.mjs index f2545c7b92b..a678a4f2436 100644 --- a/scripts/gulpfiles/release_tasks.js +++ b/scripts/gulpfiles/release_tasks.mjs @@ -8,15 +8,15 @@ * @fileoverview Gulp scripts for releasing Blockly. */ -const execSync = require('child_process').execSync; -const fs = require('fs'); -const gulp = require('gulp'); -const readlineSync = require('readline-sync'); +import {execSync} from 'child_process'; +import * as fs from 'fs'; +import * as gulp from 'gulp'; +import * as readlineSync from 'readline-sync'; -const gitTasks = require('./git_tasks'); -const packageTasks = require('./package_tasks'); -const {getPackageJson} = require('./helper_tasks'); -const {RELEASE_DIR} = require('./config'); +import * as gitTasks from './git_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import {RELEASE_DIR} from './config.mjs'; // Gets the current major version. @@ -147,17 +147,17 @@ function updateBetaVersion(done) { } // Rebuild, package and publish to npm. -const publish = gulp.series( - packageTasks.package, // Does clean + build. +export const publish = gulp.series( + packageTasks.pack, // Does clean + build. checkBranch, checkReleaseDir, loginAndPublish ); // Rebuild, package and publish a beta version of Blockly. -const publishBeta = gulp.series( +export const publishBeta = gulp.series( updateBetaVersion, - packageTasks.package, // Does clean + build. + packageTasks.pack, // Does clean + build. checkBranch, checkReleaseDir, loginAndPublishBeta @@ -165,19 +165,10 @@ const publishBeta = gulp.series( // Switch to a new branch, update the version number, build Blockly // and check in the resulting built files. -const recompileDevelop = gulp.series( +export const recompile = gulp.series( gitTasks.syncDevelop(), gitTasks.createRebuildBranch, updateVersionPrompt, - packageTasks.package, // Does clean + build. + packageTasks.pack, // Does clean + build. gitTasks.pushRebuildBranch ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - publishBeta, - publish, - - // Legacy target, to be deleted. - recompile: recompileDevelop, -}; diff --git a/scripts/gulpfiles/test_tasks.js b/scripts/gulpfiles/test_tasks.mjs similarity index 94% rename from scripts/gulpfiles/test_tasks.js rename to scripts/gulpfiles/test_tasks.mjs index 236a21d7759..d4b73cdb3c1 100644 --- a/scripts/gulpfiles/test_tasks.js +++ b/scripts/gulpfiles/test_tasks.mjs @@ -9,19 +9,19 @@ */ /* eslint-env node */ -const asyncDone = require('async-done'); -const gulp = require('gulp'); -const gzip = require('gulp-gzip'); -const fs = require('fs'); -const path = require('path'); -const {execSync} = require('child_process'); -const {rimraf} = require('rimraf'); +import asyncDone from 'async-done'; +import * as gulp from 'gulp'; +import gzip from 'gulp-gzip'; +import * as fs from 'fs'; +import * as path from 'path'; +import {execSync} from 'child_process'; +import {rimraf} from 'rimraf'; -const {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} = require('./config'); +import {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} from './config.mjs'; -const {runMochaTestsInBrowser} = require('../../tests/mocha/webdriver.js'); -const {runGeneratorsInBrowser} = require('../../tests/generators/webdriver.js'); -const {runCompileCheckInBrowser} = require('../../tests/compile/webdriver.js'); +import {runMochaTestsInBrowser} from '../../tests/mocha/webdriver.js'; +import {runGeneratorsInBrowser} from '../../tests/generators/webdriver.js'; +import {runCompileCheckInBrowser} from '../../tests/compile/webdriver.js'; const OUTPUT_DIR = 'build/generators'; const GOLDEN_DIR = 'tests/generators/golden'; @@ -321,7 +321,7 @@ function checkResult(suffix) { * Run generator tests inside a browser and check the results. * @return {Promise} Asynchronous result. */ -async function generators() { +export async function generators() { return runTestTask('generators', async () => { // Clean up. rimraf.sync(OUTPUT_DIR); @@ -396,10 +396,6 @@ const tasks = [ advancedCompileInBrowser ]; -const test = gulp.series(...tasks, reportTestResult); +export const test = gulp.series(...tasks, reportTestResult); -module.exports = { - test, - generators, -}; From fa93ba2a2f98722a7a0e864b95dceb21dfdca059 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:42:25 +0100 Subject: [PATCH 40/67] chore(deps): bump glob from 11.0.2 to 11.0.3 (#9189) Bumps [glob](https://github.com/isaacs/node-glob) from 11.0.2 to 11.0.3. - [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md) - [Commits](https://github.com/isaacs/node-glob/compare/v11.0.2...v11.0.3) --- updated-dependencies: - dependency-name: glob dependency-version: 11.0.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 68 ++++++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8696a841805..78d4f61dd8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -813,6 +813,27 @@ "url": "https://github.com/sponsors/jdesrosiers" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4768,12 +4789,12 @@ } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -5018,15 +5039,14 @@ } }, "node_modules/glob": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", - "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "dev": true, - "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -5097,24 +5117,13 @@ "node": ">= 10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { "node": "20 || >=22" @@ -6279,11 +6288,10 @@ } }, "node_modules/jackspeak": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", - "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, From 460c8c8d1b99337cdb5345c768abb69207fec75e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:17:11 -0700 Subject: [PATCH 41/67] chore(deps): bump @blockly/block-test from 6.0.11 to 7.0.1 (#9192) --- updated-dependencies: - dependency-name: "@blockly/block-test" dependency-version: 7.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 23 +++++------------------ package.json | 2 +- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78d4f61dd8e..20706831bc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "jsdom": "26.1.0" }, "devDependencies": { - "@blockly/block-test": "^6.0.4", + "@blockly/block-test": "^7.0.1", "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", @@ -89,15 +89,15 @@ "license": "ISC" }, "node_modules/@blockly/block-test": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-6.0.11.tgz", - "integrity": "sha512-aIgcxkof1gLJtJXKSvmnug9iSXbv5Qilnov4Sa/QNURiWJRxvMNqWiTZJVu/reuCQK4Qm4jadg9R9l+eu7ujvw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.1.tgz", + "integrity": "sha512-w91ZZbpJDKGQJVO7gKqQaM17ffcsW1ktrnSTz/OpDw5R4H+1q05NgWO5gYzGPzLfFdvPcrkc0v00KhD4UG7BRA==", "dev": true, "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/dev-tools": { @@ -126,19 +126,6 @@ "blockly": "^12.0.0" } }, - "node_modules/@blockly/dev-tools/node_modules/@blockly/block-test": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.1.tgz", - "integrity": "sha512-w91ZZbpJDKGQJVO7gKqQaM17ffcsW1ktrnSTz/OpDw5R4H+1q05NgWO5gYzGPzLfFdvPcrkc0v00KhD4UG7BRA==", - "dev": true, - "license": "Apache 2.0", - "engines": { - "node": ">=8.17.0" - }, - "peerDependencies": { - "blockly": "^12.0.0" - } - }, "node_modules/@blockly/dev-tools/node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", diff --git a/package.json b/package.json index 6ed5e4ea4cc..030eed6fd91 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@blockly/block-test": "^6.0.4", + "@blockly/block-test": "^7.0.1", "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", From fd3a7567640f3dd9122cb5f817b8c71e446c7890 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 1 Jul 2025 11:05:30 -0700 Subject: [PATCH 42/67] fix: Fix loss of focus when un/redoing block deletions or moves. (#9195) --- core/block_svg.ts | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index a30cc34ed9c..49b4a1ee6f6 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -299,8 +299,19 @@ export class BlockSvg } const oldXY = this.getRelativeToSurfaceXY(); + const focusedNode = getFocusManager().getFocusedNode(); + const restoreFocus = this.getSvgRoot().contains( + focusedNode?.getFocusableElement() ?? null, + ); if (newParent) { (newParent as BlockSvg).getSvgRoot().appendChild(svgRoot); + // appendChild() clears focus state, so re-focus the previously focused + // node in case it was this block and would otherwise lose its focus. Once + // Element.moveBefore() has better browser support, it should be used + // instead. + if (restoreFocus && focusedNode) { + getFocusManager().focusNode(focusedNode); + } } else if (oldParent) { // If we are losing a parent, we want to move our DOM element to the // root of the workspace. Try to insert it before any top-level @@ -319,6 +330,13 @@ export class BlockSvg canvas.insertBefore(svgRoot, draggingBlockElement); } else { canvas.appendChild(svgRoot); + // appendChild() clears focus state, so re-focus the previously focused + // node in case it was this block and would otherwise lose its focus. Once + // Element.moveBefore() has better browser support, it should be used + // instead. + if (restoreFocus && focusedNode) { + getFocusManager().focusNode(focusedNode); + } } this.translate(oldXY.x, oldXY.y); } @@ -849,10 +867,30 @@ export class BlockSvg Tooltip.dispose(); ContextMenu.hide(); - // If this block was focused, focus its parent or workspace instead. + // If this block (or a descendant) was focused, focus its parent or + // workspace instead. const focusManager = getFocusManager(); - if (focusManager.getFocusedNode() === this) { - const parent = this.getParent(); + if ( + this.getSvgRoot().contains( + focusManager.getFocusedNode()?.getFocusableElement() ?? null, + ) + ) { + let parent: BlockSvg | undefined | null = this.getParent(); + if (!parent) { + // In some cases, blocks are disconnected from their parents before + // being deleted. Attempt to infer if there was a parent by checking + // for a connection within a radius of 0. Even if this wasn't a parent, + // it must be adjacent to this block and so is as good an option as any + // to focus after deleting. + const connection = this.outputConnection ?? this.previousConnection; + if (connection) { + const targetConnection = connection.closest( + 0, + new Coordinate(0, 0), + ).connection; + parent = targetConnection?.getSourceBlock(); + } + } if (parent) { focusManager.focusNode(parent); } else { From 0f73bd53d4ee9fd0f78e612160de492e3bc4e769 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:42:46 -0700 Subject: [PATCH 43/67] chore(deps): bump mocha from 11.7.0 to 11.7.1 (#9193) --- updated-dependencies: - dependency-name: mocha dependency-version: 11.7.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 20706831bc1..3b9a934a388 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6910,11 +6910,10 @@ } }, "node_modules/mocha": { - "version": "11.7.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.0.tgz", - "integrity": "sha512-bXfLy/mI8n4QICg+pWj1G8VduX5vC0SHRwFpiR5/Fxc8S2G906pSfkyMmHVsdJNQJQNh3LE67koad9GzEvkV6g==", + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.1.tgz", + "integrity": "sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==", "dev": true, - "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", From 19da66c5322c9cda52fce24bd922c5472077b4d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:46:13 -0700 Subject: [PATCH 44/67] chore(deps): bump gulp from 5.0.0 to 5.0.1 (#9188) Bumps [gulp](https://github.com/gulpjs/gulp) from 5.0.0 to 5.0.1. - [Release notes](https://github.com/gulpjs/gulp/releases) - [Changelog](https://github.com/gulpjs/gulp/blob/master/CHANGELOG.md) - [Commits](https://github.com/gulpjs/gulp/compare/v5.0.0...v5.0.1) --- updated-dependencies: - dependency-name: gulp dependency-version: 5.0.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 229 +++++++++++----------------------------------- 1 file changed, 53 insertions(+), 176 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b9a934a388..a778f6d83d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2248,30 +2248,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/archiver-utils/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/archiver-utils/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2394,30 +2370,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/archiver/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/archiver/node_modules/buffer-crc32": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", @@ -2800,30 +2752,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2858,6 +2786,30 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -3173,30 +3125,6 @@ "node": ">= 14" } }, - "node_modules/compress-commons/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/compress-commons/node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -3407,30 +3335,6 @@ "node": ">= 14" } }, - "node_modules/crc32-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/crc32-stream/node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -5061,9 +4965,9 @@ } }, "node_modules/glob-stream": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.2.tgz", - "integrity": "sha512-R8z6eTB55t3QeZMmU1C+Gv+t5UnNRkA55c5yo67fAVfxODxieTwsjNG7utxS/73NdP1NbDgCrhVEg2h00y4fFw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.3.tgz", + "integrity": "sha512-fqZVj22LtFJkHODT+M4N1RJQ3TjnnQhfE9GwZI8qXscYarnhpip70poMldRnP8ipQ/w0B621kOhfc53/J9bd/A==", "dev": true, "dependencies": { "@gulpjs/to-absolute-glob": "^4.0.0", @@ -5292,15 +5196,15 @@ "license": "MIT" }, "node_modules/gulp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.0.tgz", - "integrity": "sha512-S8Z8066SSileaYw1S2N1I64IUc/myI2bqe2ihOBzO6+nKpvNSg7ZcWJt/AwF8LC/NVN+/QZ560Cb/5OPsyhkhg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.1.tgz", + "integrity": "sha512-PErok3DZSA5WGMd6XXV3IRNO0mlB+wW3OzhFJLEec1jSERg2j1bxJ6e5Fh6N6fn3FH2T9AP4UYNb/pYlADB9sA==", "dev": true, "dependencies": { "glob-watcher": "^6.0.0", - "gulp-cli": "^3.0.0", + "gulp-cli": "^3.1.0", "undertaker": "^2.0.0", - "vinyl-fs": "^4.0.0" + "vinyl-fs": "^4.0.2" }, "bin": { "gulp": "bin/gulp.js" @@ -5310,9 +5214,9 @@ } }, "node_modules/gulp-cli": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.0.0.tgz", - "integrity": "sha512-RtMIitkT8DEMZZygHK2vEuLPqLPAFB4sntSxg4NoDta7ciwGZ18l7JuhCTiS5deOJi2IoK0btE+hs6R4sfj7AA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.1.0.tgz", + "integrity": "sha512-zZzwlmEsTfXcxRKiCHsdyjZZnFvXWM4v1NqBJSYbuApkvVKivjcmOS2qruAJ+PkEHLFavcDKH40DPc1+t12a9Q==", "dev": true, "dependencies": { "@gulpjs/messages": "^1.1.0", @@ -5320,7 +5224,7 @@ "copy-props": "^4.0.0", "gulplog": "^2.2.0", "interpret": "^3.1.1", - "liftoff": "^5.0.0", + "liftoff": "^5.0.1", "mute-stdout": "^2.0.0", "replace-homedir": "^2.0.0", "semver-greatest-satisfied-range": "^2.0.0", @@ -6582,9 +6486,9 @@ } }, "node_modules/liftoff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.0.tgz", - "integrity": "sha512-a5BQjbCHnB+cy+gsro8lXJ4kZluzOijzJ1UVVfyJYZC+IP2pLv1h4+aysQeKuTmyO8NAqfyQAk4HWaP/HjcKTg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.1.tgz", + "integrity": "sha512-wwLXMbuxSF8gMvubFcFRp56lkFV69twvbU5vDPbaw+Q+/rF8j0HKjGbIdlSi+LuJm9jf7k9PB+nTxnsLMPcv2Q==", "dev": true, "dependencies": { "extend": "^3.0.2", @@ -9441,13 +9345,12 @@ } }, "node_modules/vinyl-contents/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -9457,13 +9360,13 @@ } }, "node_modules/vinyl-fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.0.tgz", - "integrity": "sha512-7GbgBnYfaquMk3Qu9g22x000vbYkOex32930rBnc3qByw6HfMEAoELjCjoJv4HuEQxHAurT+nvMHm6MnJllFLw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.2.tgz", + "integrity": "sha512-XRFwBLLTl8lRAOYiBqxY279wY46tVxLaRhSwo3GzKEuLz1giffsOquWWboD/haGf5lx+JyTigCFfe7DWHoARIA==", "dev": true, "dependencies": { "fs-mkdirp-stream": "^2.0.1", - "glob-stream": "^8.0.0", + "glob-stream": "^8.0.3", "graceful-fs": "^4.2.11", "iconv-lite": "^0.6.3", "is-valid-glob": "^1.0.0", @@ -9474,7 +9377,7 @@ "streamx": "^2.14.0", "to-through": "^3.0.0", "value-or-function": "^4.0.0", - "vinyl": "^3.0.0", + "vinyl": "^3.0.1", "vinyl-sourcemap": "^2.0.0" }, "engines": { @@ -9482,13 +9385,12 @@ } }, "node_modules/vinyl-fs/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -9521,13 +9423,12 @@ "dev": true }, "node_modules/vinyl-sourcemap/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -9969,30 +9870,6 @@ "node": ">= 14" } }, - "node_modules/zip-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/zip-stream/node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", From c426c6d820495d2b34dec7ac6a2fe62cfb02506c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 1 Jul 2025 14:07:39 -0700 Subject: [PATCH 45/67] fix: Short-circuit node lookups for missing IDs (#9174) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9155 ### Proposed Changes In cases when an ID is missing for an element passed to `FocusableTreeTraverser.findFocusableNodeFor()`, always return `null`. Additionally, the new short-circuit logic exposed that `Toolbox` actually wasn't being set up correctly (that is, its root element was not being configured with a valid ID). This has been fixed. ### Reason for Changes These are cases when a valid node should never be matched (and it's technically possible to incorrectly match if an `IFocusableNode` is set up incorrectly and is providing a focusable element with an unset ID). This avoids the extra computation time of potentially calling deep into `WorkspaceSvg` and exploring all possible nodes for an ID that should never match. Note that there is a weird quirk with `null` IDs actually being the string `"null"`. This is a side effect of how `setAttribute` and attributes in general work with HTML elements. There's nothing really that can be done here, so it's now considered invalid to also have an ID of string `"null"` just to ensure the `null` case is properly short-circuited. Finally, the issue with toolbox being configured incorrectly was discovered with the introducing of a new hard failure in `FocusManager.registerTree()` when a tree with an invalid root element is registered. From testing there are no other such trees that need to be updated. A new warning was also added if `focusNode()` is used on a node with an element that has an invalid ID. This isn't a hard failure to follow the convention of other invalid `focusNode()` situations. It's much more fragile for `focusNode()` to throw than `registerTree()` since the former generally happens much earlier in a page lifecycle, and is less prone to dynamic behaviors. ### Test Coverage New tests were added to validate the various empty ID cases for `FocusableTreeTraverser.findFocusableNodeFor()`, and to validate the new error check for `FocusManager.registerTree()`. ### Documentation No new documentation should be needed. ### Additional Information Nothing to add. --- core/focus_manager.ts | 20 +++++- core/toolbox/toolbox.ts | 2 + core/utils/focusable_tree_traverser.ts | 8 ++- tests/mocha/focus_manager_test.js | 48 +++++++++++++ tests/mocha/focusable_tree_traverser_test.js | 74 ++++++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 3d0a9347f85..02e0591070f 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -174,8 +174,15 @@ export class FocusManager { this.registeredTrees.push( new TreeRegistration(tree, rootShouldBeAutoTabbable), ); + const rootElement = tree.getRootFocusableNode().getFocusableElement(); + if (!rootElement.id || rootElement.id === 'null') { + throw Error( + `Attempting to register a tree with a root element that has an ` + + `invalid ID: ${tree}.`, + ); + } if (rootShouldBeAutoTabbable) { - tree.getRootFocusableNode().getFocusableElement().tabIndex = 0; + rootElement.tabIndex = 0; } } @@ -344,13 +351,22 @@ export class FocusManager { throw Error(`Attempted to focus unregistered node: ${focusableNode}.`); } + const focusableNodeElement = focusableNode.getFocusableElement(); + if (!focusableNodeElement.id || focusableNodeElement.id === 'null') { + // Warn that the ID is invalid, but continue execution since an invalid ID + // will result in an unmatched (null) node. Since a request to focus + // something was initiated, the code below will attempt to find the next + // best thing to focus, instead. + console.warn('Trying to focus a node that has an invalid ID.'); + } + // Safety check for ensuring focusNode() doesn't get called for a node that // isn't actually hooked up to its parent tree correctly. This usually // happens when calls to focusNode() interleave with asynchronous clean-up // operations (which can happen due to ephemeral focus and in other cases). // Fall back to a reasonable default since there's no valid node to focus. const matchedNode = FocusableTreeTraverser.findFocusableNodeFor( - focusableNode.getFocusableElement(), + focusableNodeElement, nextTree, ); const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree); diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 57e849ce264..31bb2b6363d 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -43,6 +43,7 @@ import type {KeyboardShortcut} from '../shortcut_registry.js'; import * as Touch from '../touch.js'; import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; +import * as idGenerator from '../utils/idgenerator.js'; import {Rect} from '../utils/rect.js'; import * as toolbox from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -185,6 +186,7 @@ export class Toolbox const svg = workspace.getParentSvg(); const container = this.createContainer_(); + container.id = idGenerator.getNextUniqueId(); this.contentsDiv_ = this.createContentsContainer_(); aria.setRole(this.contentsDiv_, aria.Role.TREE); diff --git a/core/utils/focusable_tree_traverser.ts b/core/utils/focusable_tree_traverser.ts index 916437b6a73..aa4585b828d 100644 --- a/core/utils/focusable_tree_traverser.ts +++ b/core/utils/focusable_tree_traverser.ts @@ -79,8 +79,8 @@ export class FocusableTreeTraverser { * traversed but its nodes will never be returned here per the contract of * IFocusableTree.lookUpFocusableNode. * - * The provided element must have a non-null ID that conforms to the contract - * mentioned in IFocusableNode. + * The provided element must have a non-null, non-empty ID that conforms to + * the contract mentioned in IFocusableNode. * * @param element The HTML or SVG element being sought. * @param tree The tree under which the provided element may be a descendant. @@ -90,6 +90,10 @@ export class FocusableTreeTraverser { element: HTMLElement | SVGElement, tree: IFocusableTree, ): IFocusableNode | null { + // Note that the null check is due to Element.setAttribute() converting null + // to a string. + if (!element.id || element.id === 'null') return null; + // First, match against subtrees. const subTreeMatches = tree.getNestedTrees().map((tree) => { return FocusableTreeTraverser.findFocusableNodeFor(element, tree); diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index 3a1fc98a7e5..26dcb8dbe68 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -249,6 +249,54 @@ suite('FocusManager', function () { // The second register should not fail since the tree was previously unregistered. }); + test('for tree with missing ID throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.removeAttribute('id'); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + + test('for tree with null ID throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.setAttribute('id', null); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + + test('for tree with empty throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.setAttribute('id', ''); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + test('for unmanaged tree does not overwrite tab index', function () { this.focusManager.registerTree(this.testFocusableTree1, false); diff --git a/tests/mocha/focusable_tree_traverser_test.js b/tests/mocha/focusable_tree_traverser_test.js index 66cc598ccf5..0f88e1106f9 100644 --- a/tests/mocha/focusable_tree_traverser_test.js +++ b/tests/mocha/focusable_tree_traverser_test.js @@ -348,6 +348,80 @@ suite('FocusableTreeTraverser', function () { }); suite('findFocusableNodeFor()', function () { + test('for element without ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // Normally it's not valid to miss an ID, but it can realistically happen. + rootElem.removeAttribute('id'); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with null ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // Normally it's not valid to miss an ID, but it can realistically happen. + rootElem.setAttribute('id', null); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with null ID string returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // This is a quirky version of the null variety above that's actually + // functionallity equivalent (since 'null' is converted to a string). + rootElem.setAttribute('id', 'null'); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with empty ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // An empty ID is invalid since it will potentially conflict with other + // elements, and element IDs must be unique for focus management. + rootElem.setAttribute('id', ''); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + test('for root element returns root', function () { const tree = this.testFocusableTree1; const rootNode = tree.getRootFocusableNode(); From e5804e709563f6e939a2a7957c3150403414c7b1 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 1 Jul 2025 15:13:13 -0700 Subject: [PATCH 46/67] feat: Add support for keyboard navigation in/to workspace comments. (#9182) * feat: Enhance the Rect API. * feat: Add support for sorting IBoundedElements in general. * fix: Improve typings of getTopElement/Comment methods. * feat: Add classes to represent comment icons. * refactor: Use comment icons in comment view. * feat: Update navigation policies to support workspace comments. * feat: Make the navigator and workspace handle workspace comments. * feat: Visit workspace comments when navigating with the up/down arrows. * chore: Make the linter happy. * chore: Rename comment icons to bar buttons. * refactor: Rename CommentIcons to CommentBarButtons. * chore: Improve docstrings. * chore: Clarify unit type. * refactor: Remove workspace argument from `navigateStacks()`. * fix: Fix errant find and replace in CSS. * fix: Fix issue that could cause delete button to become misaligned. --- core/comments.ts | 3 + core/comments/collapse_comment_bar_button.ts | 101 ++++++++++ core/comments/comment_bar_button.ts | 105 ++++++++++ core/comments/comment_view.ts | 190 +++++------------- core/comments/delete_comment_bar_button.ts | 102 ++++++++++ core/keyboard_nav/block_navigation_policy.ts | 29 ++- .../comment_bar_button_navigation_policy.ts | 86 ++++++++ core/keyboard_nav/line_cursor.ts | 27 +-- .../workspace_comment_navigation_policy.ts | 77 +++++++ core/navigator.ts | 4 + core/utils/rect.ts | 15 ++ core/workspace.ts | 27 ++- core/workspace_svg.ts | 68 +++++-- 13 files changed, 652 insertions(+), 182 deletions(-) create mode 100644 core/comments/collapse_comment_bar_button.ts create mode 100644 core/comments/comment_bar_button.ts create mode 100644 core/comments/delete_comment_bar_button.ts create mode 100644 core/keyboard_nav/comment_bar_button_navigation_policy.ts create mode 100644 core/keyboard_nav/workspace_comment_navigation_policy.ts diff --git a/core/comments.ts b/core/comments.ts index 86e8f50b95c..179ab4a33d0 100644 --- a/core/comments.ts +++ b/core/comments.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js'; +export {CommentBarButton} from './comments/comment_bar_button.js'; export {CommentEditor} from './comments/comment_editor.js'; export {CommentView} from './comments/comment_view.js'; +export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js'; export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; export {WorkspaceComment} from './comments/workspace_comment.js'; diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts new file mode 100644 index 00000000000..b0738d70705 --- /dev/null +++ b/core/comments/collapse_comment_bar_button.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentBarButton} from './comment_bar_button.js'; + +/** + * Magic string appended to the comment ID to create a unique ID for this button. + */ +export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER = + '_collapse_bar_button'; + +/** + * Button that toggles the collapsed state of a comment. + */ +export class CollapseCommentBarButton extends CommentBarButton { + /** + * Opaque ID used to unbind event handlers during disposal. + */ + private readonly bindId: browserEvents.Data; + + /** + * SVG image displayed on this button. + */ + protected override readonly icon: SVGImageElement; + + /** + * Creates a new CollapseCommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is displayed on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) { + super(id, workspace, container); + + this.icon = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyFoldoutIcon', + 'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`, + 'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`, + }, + this.container, + ); + this.bindId = browserEvents.conditionalBind( + this.icon, + 'pointerdown', + this, + this.performAction.bind(this), + ); + } + + /** + * Disposes of this button. + */ + dispose() { + browserEvents.unbind(this.bindId); + } + + /** + * Adjusts the positioning of this button within its container. + */ + override reposition() { + const margin = this.getMargin(); + this.icon.setAttribute('y', `${margin}`); + this.icon.setAttribute('x', `${margin}`); + } + + /** + * Toggles the collapsed state of the parent comment. + * + * @param e The event that triggered this action. + */ + override performAction(e?: Event) { + touch.clearTouchIdentifier(); + + const comment = this.getParentComment(); + comment.view.bringToFront(); + if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + comment.setCollapsed(!comment.isCollapsed()); + this.workspace.hideChaff(); + + e?.stopPropagation(); + } +} diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts new file mode 100644 index 00000000000..d78a7fd86a1 --- /dev/null +++ b/core/comments/comment_bar_button.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {Rect} from '../utils/rect.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js'; + +/** + * Button displayed on a comment's top bar. + */ +export abstract class CommentBarButton implements IFocusableNode { + /** + * SVG image displayed on this button. + */ + protected abstract readonly icon: SVGImageElement; + + /** + * Creates a new CommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) {} + + /** + * Returns whether or not this button is currently visible. + */ + isVisible(): boolean { + return this.icon.checkVisibility(); + } + + /** + * Returns the parent comment of this comment bar button. + */ + getParentComment(): RenderedWorkspaceComment { + const comment = this.workspace.getCommentById(this.id); + if (!comment) { + throw new Error( + `Comment bar button ${this.id} has no corresponding comment`, + ); + } + + return comment; + } + + /** Adjusts the position of this button within its parent container. */ + abstract reposition(): void; + + /** Perform the action this button should take when it is acted on. */ + abstract performAction(e?: Event): void; + + /** + * Returns the dimensions of this button in workspace coordinates. + * + * @param includeMargin True to include the margin when calculating the size. + * @returns The size of this button. + */ + getSize(includeMargin = false): Rect { + const bounds = this.icon.getBBox(); + const rect = Rect.from(bounds); + if (includeMargin) { + const margin = this.getMargin(); + rect.left -= margin; + rect.top -= margin; + rect.bottom += margin; + rect.right += margin; + } + return rect; + } + + /** Returns the margin in workspace coordinates surrounding this button. */ + getMargin(): number { + return (this.container.getBBox().height - this.icon.getBBox().height) / 2; + } + + /** Returns a DOM element representing this button that can receive focus. */ + getFocusableElement() { + return this.icon; + } + + /** Returns the workspace this button is a child of. */ + getFocusableTree() { + return this.workspace; + } + + /** Called when this button's focusable DOM element gains focus. */ + onNodeFocus() {} + + /** Called when this button's focusable DOM element loses focus. */ + onNodeBlur() {} + + /** Returns whether this button can be focused. True if it is visible. */ + canBeFocused() { + return this.isVisible(); + } +} diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 1e5ad4a52c2..936d746508f 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -16,14 +16,17 @@ import * as drag from '../utils/drag.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; import {WorkspaceSvg} from '../workspace_svg.js'; +import {CollapseCommentBarButton} from './collapse_comment_bar_button.js'; +import {CommentBarButton} from './comment_bar_button.js'; import {CommentEditor} from './comment_editor.js'; +import {DeleteCommentBarButton} from './delete_comment_bar_button.js'; export class CommentView implements IRenderedElement { /** The root group element of the comment view. */ private svgRoot: SVGGElement; /** - * The svg rect element that we use to create a hightlight around the comment. + * The SVG rect element that we use to create a highlight around the comment. */ private highlightRect: SVGRectElement; @@ -33,11 +36,11 @@ export class CommentView implements IRenderedElement { /** The rect background for the top bar. */ private topBarBackground: SVGRectElement; - /** The delete icon that goes in the top bar. */ - private deleteIcon: SVGImageElement; + /** The delete button that goes in the top bar. */ + private deleteButton: DeleteCommentBarButton; - /** The foldout icon that goes in the top bar. */ - private foldoutIcon: SVGImageElement; + /** The foldout button that goes in the top bar. */ + private foldoutButton: CollapseCommentBarButton; /** The text element that goes in the top bar. */ private textPreview: SVGTextElement; @@ -99,7 +102,7 @@ export class CommentView implements IRenderedElement { constructor( readonly workspace: WorkspaceSvg, - private commentId?: string, + private commentId: string, ) { this.svgRoot = dom.createSvgElement(Svg.G, { 'class': 'blocklyComment blocklyEditable blocklyDraggable', @@ -110,11 +113,11 @@ export class CommentView implements IRenderedElement { ({ topBarGroup: this.topBarGroup, topBarBackground: this.topBarBackground, - deleteIcon: this.deleteIcon, - foldoutIcon: this.foldoutIcon, + deleteButton: this.deleteButton, + foldoutButton: this.foldoutButton, textPreview: this.textPreview, textPreviewNode: this.textPreviewNode, - } = this.createTopBar(this.svgRoot, workspace)); + } = this.createTopBar(this.svgRoot)); this.commentEditor = this.createTextArea(); @@ -147,14 +150,11 @@ export class CommentView implements IRenderedElement { * Creates the top bar and the elements visually within it. * Registers event listeners. */ - private createTopBar( - svgRoot: SVGGElement, - workspace: WorkspaceSvg, - ): { + private createTopBar(svgRoot: SVGGElement): { topBarGroup: SVGGElement; topBarBackground: SVGRectElement; - deleteIcon: SVGImageElement; - foldoutIcon: SVGImageElement; + deleteButton: DeleteCommentBarButton; + foldoutButton: CollapseCommentBarButton; textPreview: SVGTextElement; textPreviewNode: Text; } { @@ -172,22 +172,14 @@ export class CommentView implements IRenderedElement { }, topBarGroup, ); - // TODO: Before merging, does this mean to override an individual image, - // folks need to replace the whole media folder? - const deleteIcon = dom.createSvgElement( - Svg.IMAGE, - { - 'class': 'blocklyDeleteIcon', - 'href': `${workspace.options.pathToMedia}delete-icon.svg`, - }, + const deleteButton = new DeleteCommentBarButton( + this.commentId, + this.workspace, topBarGroup, ); - const foldoutIcon = dom.createSvgElement( - Svg.IMAGE, - { - 'class': 'blocklyFoldoutIcon', - 'href': `${workspace.options.pathToMedia}foldout-icon.svg`, - }, + const foldoutButton = new CollapseCommentBarButton( + this.commentId, + this.workspace, topBarGroup, ); const textPreview = dom.createSvgElement( @@ -200,27 +192,11 @@ export class CommentView implements IRenderedElement { const textPreviewNode = document.createTextNode(''); textPreview.appendChild(textPreviewNode); - // TODO(toychest): Triggering this on pointerdown means that we can't start - // drags on the foldout icon. We need to open up the gesture system - // to fix this. - browserEvents.conditionalBind( - foldoutIcon, - 'pointerdown', - this, - this.onFoldoutDown, - ); - browserEvents.conditionalBind( - deleteIcon, - 'pointerdown', - this, - this.onDeleteDown, - ); - return { topBarGroup, topBarBackground, - deleteIcon, - foldoutIcon, + deleteButton, + foldoutButton, textPreview, textPreviewNode, }; @@ -300,15 +276,10 @@ export class CommentView implements IRenderedElement { */ setSizeWithoutFiringEvents(size: Size) { const topBarSize = this.topBarBackground.getBBox(); - const deleteSize = this.deleteIcon.getBBox(); - const foldoutSize = this.foldoutIcon.getBBox(); const textPreviewSize = this.textPreview.getBBox(); const resizeSize = this.resizeHandle.getBBox(); - size = Size.max( - size, - this.calcMinSize(topBarSize, foldoutSize, deleteSize), - ); + size = Size.max(size, this.calcMinSize(topBarSize)); this.size = size; this.svgRoot.setAttribute('height', `${size.height}`); @@ -317,15 +288,9 @@ export class CommentView implements IRenderedElement { this.updateHighlightRect(size); this.updateTopBarSize(size); this.commentEditor.updateSize(size, topBarSize); - this.updateDeleteIconPosition(size, topBarSize, deleteSize); - this.updateFoldoutIconPosition(topBarSize, foldoutSize); - this.updateTextPreviewSize( - size, - topBarSize, - textPreviewSize, - deleteSize, - resizeSize, - ); + this.deleteButton.reposition(); + this.foldoutButton.reposition(); + this.updateTextPreviewSize(size, topBarSize, textPreviewSize); this.updateResizeHandlePosition(size, resizeSize); } @@ -347,25 +312,18 @@ export class CommentView implements IRenderedElement { * * The minimum height is based on the height of the top bar. */ - private calcMinSize( - topBarSize: Size, - foldoutSize: Size, - deleteSize: Size, - ): Size { + private calcMinSize(topBarSize: Size): Size { this.updateTextPreview(this.commentEditor.getText() ?? ''); const textPreviewWidth = dom.getTextWidth(this.textPreview); - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - let width = textPreviewWidth; - if (this.foldoutIcon.checkVisibility()) { - width += foldoutSize.width + foldoutMargin * 2; + if (this.foldoutButton.isVisible()) { + width += this.foldoutButton.getSize(true).getWidth(); } else if (textPreviewWidth) { width += 4; // Arbitrary margin before text. } - if (this.deleteIcon.checkVisibility()) { - width += deleteSize.width + deleteMargin * 2; + if (this.deleteButton.isVisible()) { + width += this.deleteButton.getSize(true).getWidth(); } else if (textPreviewWidth) { width += 4; // Arbitrary margin after text. } @@ -376,16 +334,6 @@ export class CommentView implements IRenderedElement { return new Size(width, height); } - /** Calculates the margin that should exist around the delete icon. */ - private calcDeleteMargin(topBarSize: Size, deleteSize: Size) { - return (topBarSize.height - deleteSize.height) / 2; - } - - /** Calculates the margin that should exist around the foldout icon. */ - private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) { - return (topBarSize.height - foldoutSize.height) / 2; - } - /** Updates the size of the highlight rect to reflect the new size. */ private updateHighlightRect(size: Size) { this.highlightRect.setAttribute('height', `${size.height}`); @@ -400,31 +348,6 @@ export class CommentView implements IRenderedElement { this.topBarBackground.setAttribute('width', `${size.width}`); } - /** - * Updates the position of the delete icon elements to reflect the new size. - */ - private updateDeleteIconPosition( - size: Size, - topBarSize: Size, - deleteSize: Size, - ) { - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - this.deleteIcon.setAttribute('y', `${deleteMargin}`); - this.deleteIcon.setAttribute( - 'x', - `${size.width - deleteSize.width - deleteMargin}`, - ); - } - - /** - * Updates the position of the foldout icon elements to reflect the new size. - */ - private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) { - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); - this.foldoutIcon.setAttribute('y', `${foldoutMargin}`); - this.foldoutIcon.setAttribute('x', `${foldoutMargin}`); - } - /** * Updates the size and position of the text preview elements to reflect the new size. */ @@ -432,25 +355,14 @@ export class CommentView implements IRenderedElement { size: Size, topBarSize: Size, textPreviewSize: Size, - deleteSize: Size, - foldoutSize: Size, ) { const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2; - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); + const foldoutSize = this.foldoutButton.getSize(true); + const deleteSize = this.deleteButton.getSize(true); const textPreviewWidth = - size.width - - foldoutSize.width - - foldoutMargin * 2 - - deleteSize.width - - deleteMargin * 2; - this.textPreview.setAttribute( - 'x', - `${ - foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1) - }`, - ); + size.width - foldoutSize.getWidth() - deleteSize.getWidth(); + this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`); this.textPreview.setAttribute( 'y', `${textPreviewMargin + textPreviewSize.height / 2}`, @@ -601,25 +513,6 @@ export class CommentView implements IRenderedElement { ); } - /** - * Toggles the collapsedness of the block when we receive a pointer down - * event on the foldout icon. - */ - private onFoldoutDown(e: PointerEvent) { - touch.clearTouchIdentifier(); - this.bringToFront(); - if (browserEvents.isRightButton(e)) { - e.stopPropagation(); - return; - } - - this.setCollapsed(!this.collapsed); - - this.workspace.hideChaff(); - - e.stopPropagation(); - } - /** Returns true if the comment is currently editable. */ isEditable(): boolean { return this.editable; @@ -692,7 +585,7 @@ export class CommentView implements IRenderedElement { } /** Brings the workspace comment to the front of its layer. */ - private bringToFront() { + bringToFront() { const parent = this.svgRoot.parentNode; const childNodes = parent!.childNodes; // Avoid moving the comment if it's already at the bottom. @@ -719,6 +612,8 @@ export class CommentView implements IRenderedElement { /** Disposes of this comment view. */ dispose() { this.disposing = true; + this.foldoutButton.dispose(); + this.deleteButton.dispose(); dom.removeNode(this.svgRoot); // Loop through listeners backwards in case they remove themselves. for (let i = this.disposeListeners.length - 1; i >= 0; i--) { @@ -749,6 +644,13 @@ export class CommentView implements IRenderedElement { removeDisposeListener(listener: () => void) { this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1); } + + /** + * @internal + */ + getCommentBarButtons(): CommentBarButton[] { + return [this.foldoutButton, this.deleteButton]; + } } css.register(` diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts new file mode 100644 index 00000000000..ccdd0253916 --- /dev/null +++ b/core/comments/delete_comment_bar_button.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentBarButton} from './comment_bar_button.js'; + +/** + * Magic string appended to the comment ID to create a unique ID for this button. + */ +export const COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER = '_delete_bar_button'; + +/** + * Button that deletes a comment. + */ +export class DeleteCommentBarButton extends CommentBarButton { + /** + * Opaque ID used to unbind event handlers during disposal. + */ + private readonly bindId: browserEvents.Data; + + /** + * SVG image displayed on this button. + */ + protected override readonly icon: SVGImageElement; + + /** + * Creates a new DeleteCommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is shown on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) { + super(id, workspace, container); + + this.icon = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyDeleteIcon', + 'href': `${this.workspace.options.pathToMedia}delete-icon.svg`, + 'id': `${this.id}${COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER}`, + }, + container, + ); + this.bindId = browserEvents.conditionalBind( + this.icon, + 'pointerdown', + this, + this.performAction.bind(this), + ); + } + + /** + * Disposes of this button. + */ + dispose() { + browserEvents.unbind(this.bindId); + } + + /** + * Adjusts the positioning of this button within its container. + */ + override reposition() { + const margin = this.getMargin(); + // Reset to 0 so that our position doesn't force the parent container to + // grow. + this.icon.setAttribute('x', `0`); + const containerSize = this.container.getBBox(); + this.icon.setAttribute('y', `${margin}`); + this.icon.setAttribute( + 'x', + `${containerSize.width - this.getSize(true).getWidth()}`, + ); + } + + /** + * Deletes parent comment. + * + * @param e The event that triggered this action. + */ + override performAction(e?: Event) { + touch.clearTouchIdentifier(); + if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + this.getParentComment().dispose(); + e?.stopPropagation(); + } +} diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 2637ad49df5..9f56b538455 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -8,8 +8,11 @@ import {BlockSvg} from '../block_svg.js'; import {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; import type {Icon} from '../icons/icon.js'; +import type {IBoundedElement} from '../interfaces/i_bounded_element.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import type {ISelectable} from '../interfaces/i_selectable.js'; import {RenderedConnection} from '../rendered_connection.js'; import {WorkspaceSvg} from '../workspace_svg.js'; @@ -143,21 +146,25 @@ function getBlockNavigationCandidates( } /** - * Returns the next/previous stack relative to the given block's stack. + * Returns the next/previous stack relative to the given element's stack. * - * @param current The block whose stack will be navigated relative to. + * @param current The element whose stack will be navigated relative to. * @param delta The difference in index to navigate; positive values navigate * to the nth next stack, while negative values navigate to the nth previous * stack. - * @returns The first block in the stack offset by `delta` relative to the - * current block's stack, or the last block in the stack offset by `delta` - * relative to the current block's stack when navigating backwards. + * @returns The first element in the stack offset by `delta` relative to the + * current element's stack, or the last element in the stack offset by + * `delta` relative to the current element's stack when navigating backwards. */ -export function navigateStacks(current: BlockSvg, delta: number) { - const stacks = current.workspace.getTopBlocks(true); - const currentIndex = stacks.indexOf(current.getRootBlock()); +export function navigateStacks(current: ISelectable, delta: number) { + const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg) + .getTopBoundedElements(true) + .filter((element: IBoundedElement) => isFocusableNode(element)); + const currentIndex = stacks.indexOf( + current instanceof BlockSvg ? current.getRootBlock() : current, + ); const targetIndex = currentIndex + delta; - let result: BlockSvg | null = null; + let result: IFocusableNode | null = null; if (targetIndex >= 0 && targetIndex < stacks.length) { result = stacks[targetIndex]; } else if (targetIndex < 0) { @@ -166,9 +173,9 @@ export function navigateStacks(current: BlockSvg, delta: number) { result = stacks[0]; } - // When navigating to a previous stack, our previous sibling is the last + // When navigating to a previous block stack, our previous sibling is the last // block in it. - if (delta < 0 && result) { + if (delta < 0 && result instanceof BlockSvg) { return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; } diff --git a/core/keyboard_nav/comment_bar_button_navigation_policy.ts b/core/keyboard_nav/comment_bar_button_navigation_policy.ts new file mode 100644 index 00000000000..f676f465582 --- /dev/null +++ b/core/keyboard_nav/comment_bar_button_navigation_policy.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentBarButton} from '../comments/comment_bar_button.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a CommentBarButton. + */ +export class CommentBarButtonNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given CommentBarButton. + * + * @param _current The CommentBarButton to return the first child of. + * @returns Null. + */ + getFirstChild(_current: CommentBarButton): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given CommentBarButton. + * + * @param current The CommentBarButton to return the parent of. + * @returns The parent comment of the given CommentBarButton. + */ + getParent(current: CommentBarButton): IFocusableNode | null { + return current.getParentComment(); + } + + /** + * Returns the next peer node of the given CommentBarButton. + * + * @param current The CommentBarButton to find the following element of. + * @returns The next CommentBarButton, if any. + */ + getNextSibling(current: CommentBarButton): IFocusableNode | null { + const children = current.getParentComment().view.getCommentBarButtons(); + const currentIndex = children.indexOf(current); + if (currentIndex >= 0 && currentIndex + 1 < children.length) { + return children[currentIndex + 1]; + } + return null; + } + + /** + * Returns the previous peer node of the given CommentBarButton. + * + * @param current The CommentBarButton to find the preceding element of. + * @returns The CommentBarButton's previous CommentBarButton, if any. + */ + getPreviousSibling(current: CommentBarButton): IFocusableNode | null { + const children = current.getParentComment().view.getCommentBarButtons(); + const currentIndex = children.indexOf(current); + if (currentIndex > 0) { + return children[currentIndex - 1]; + } + return null; + } + + /** + * Returns whether or not the given CommentBarButton can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given CommentBarButton can be focused. + */ + isNavigable(current: CommentBarButton): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an CommentBarButton. + */ + isApplicable(current: any): current is CommentBarButton { + return current instanceof CommentBarButton; + } +} diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 89668dedb49..aeb80cff170 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -14,6 +14,7 @@ */ import {BlockSvg} from '../block_svg.js'; +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; import {Field} from '../field.js'; import {getFocusManager} from '../focus_manager.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; @@ -38,11 +39,11 @@ export class LineCursor extends Marker { } /** - * Moves the cursor to the next previous connection, next connection or block - * in the pre order traversal. Finds the next node in the pre order traversal. + * Moves the cursor to the next block or workspace comment in the pre-order + * traversal. * - * @returns The next node, or null if the current node is - * not set or there is no next value. + * @returns The next node, or null if the current node is not set or there is + * no next value. */ next(): IFocusableNode | null { const curNode = this.getCurNode(); @@ -53,8 +54,9 @@ export class LineCursor extends Marker { curNode, (candidate: IFocusableNode | null) => { return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment ); }, true, @@ -87,11 +89,11 @@ export class LineCursor extends Marker { return newNode; } /** - * Moves the cursor to the previous next connection or previous connection in - * the pre order traversal. + * Moves the cursor to the previous block or workspace comment in the + * pre-order traversal. * - * @returns The previous node, or null if the current node - * is not set or there is no previous value. + * @returns The previous node, or null if the current node is not set or there + * is no previous value. */ prev(): IFocusableNode | null { const curNode = this.getCurNode(); @@ -102,8 +104,9 @@ export class LineCursor extends Marker { curNode, (candidate: IFocusableNode | null) => { return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment ); }, true, diff --git a/core/keyboard_nav/workspace_comment_navigation_policy.ts b/core/keyboard_nav/workspace_comment_navigation_policy.ts new file mode 100644 index 00000000000..7fe70ceadef --- /dev/null +++ b/core/keyboard_nav/workspace_comment_navigation_policy.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {navigateStacks} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from an RenderedWorkspaceComment. + */ +export class WorkspaceCommentNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given workspace comment. + * + * @param current The workspace comment to return the first child of. + * @returns The first child button of the given comment. + */ + getFirstChild(current: RenderedWorkspaceComment): IFocusableNode | null { + return current.view.getCommentBarButtons()[0]; + } + + /** + * Returns the parent of the given workspace comment. + * + * @param current The workspace comment to return the parent of. + * @returns The parent workspace of the given comment. + */ + getParent(current: RenderedWorkspaceComment): IFocusableNode | null { + return current.workspace; + } + + /** + * Returns the next peer node of the given workspace comment. + * + * @param current The workspace comment to find the following element of. + * @returns The next workspace comment or block stack, if any. + */ + getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null { + return navigateStacks(current, 1); + } + + /** + * Returns the previous peer node of the given workspace comment. + * + * @param current The workspace comment to find the preceding element of. + * @returns The previous workspace comment or block stack, if any. + */ + getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null { + return navigateStacks(current, -1); + } + + /** + * Returns whether or not the given workspace comment can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given workspace comment can be focused. + */ + isNavigable(current: RenderedWorkspaceComment): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an RenderedWorkspaceComment. + */ + isApplicable(current: any): current is RenderedWorkspaceComment { + return current instanceof RenderedWorkspaceComment; + } +} diff --git a/core/navigator.ts b/core/navigator.ts index 77bb64cd8c7..2f095f6f962 100644 --- a/core/navigator.ts +++ b/core/navigator.ts @@ -7,9 +7,11 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; +import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js'; import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js'; +import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js'; import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; type RuleList = INavigationPolicy[]; @@ -29,6 +31,8 @@ export class Navigator { new ConnectionNavigationPolicy(), new WorkspaceNavigationPolicy(), new IconNavigationPolicy(), + new WorkspaceCommentNavigationPolicy(), + new CommentBarButtonNavigationPolicy(), ]; /** diff --git a/core/utils/rect.ts b/core/utils/rect.ts index c7da2a6860b..5a6822633e1 100644 --- a/core/utils/rect.ts +++ b/core/utils/rect.ts @@ -32,6 +32,16 @@ export class Rect { public right: number, ) {} + /** + * Converts a DOM or SVG Rect to a Blockly Rect. + * + * @param rect The rectangle to convert. + * @returns A representation of the same rectangle as a Blockly Rect. + */ + static from(rect: DOMRect | SVGRect): Rect { + return new Rect(rect.y, rect.y + rect.height, rect.x, rect.x + rect.width); + } + /** * Creates a new copy of this rectangle. * @@ -51,6 +61,11 @@ export class Rect { return this.right - this.left; } + /** Returns the top left coordinate of this rectangle. */ + getOrigin(): Coordinate { + return new Coordinate(this.left, this.top); + } + /** * Tests whether this rectangle contains a x/y coordinate. * diff --git a/core/workspace.ts b/core/workspace.ts index f7b866447c4..5f205193912 100644 --- a/core/workspace.ts +++ b/core/workspace.ts @@ -21,6 +21,7 @@ import * as common from './common.js'; import type {ConnectionDB} from './connection_db.js'; import type {Abstract} from './events/events_abstract.js'; import * as eventUtils from './events/utils.js'; +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; import {IProcedureMap} from './interfaces/i_procedure_map.js'; import type {IVariableMap} from './interfaces/i_variable_map.js'; @@ -35,6 +36,7 @@ import * as arrayUtils from './utils/array.js'; import * as deprecation from './utils/deprecation.js'; import * as idGenerator from './utils/idgenerator.js'; import * as math from './utils/math.js'; +import {Rect} from './utils/rect.js'; import type * as toolbox from './utils/toolbox.js'; import {deleteVariable, getVariableUsesById} from './variables.js'; @@ -181,10 +183,31 @@ export class Workspace { a: Block | WorkspaceComment, b: Block | WorkspaceComment, ): number { + const wrap = (element: Block | WorkspaceComment) => { + return { + getBoundingRectangle: () => { + const xy = element.getRelativeToSurfaceXY(); + return new Rect(xy.y, xy.y, xy.x, xy.x); + }, + moveBy: () => {}, + }; + }; + return this.sortByOrigin(wrap(a), wrap(b)); + } + + /** + * Sorts bounded elements on the workspace by their relative position, top to + * bottom (with slight LTR or RTL bias). + * + * @param a The first element to sort. + * @param b The second elment to sort. + * @returns -1, 0 or 1 depending on the sort order. + */ + protected sortByOrigin(a: IBoundedElement, b: IBoundedElement): number { const offset = Math.sin(math.toRadians(Workspace.SCAN_ANGLE)) * (this.RTL ? -1 : 1); - const aXY = a.getRelativeToSurfaceXY(); - const bXY = b.getRelativeToSurfaceXY(); + const aXY = a.getBoundingRectangle().getOrigin(); + const bXY = b.getBoundingRectangle().getOrigin(); return aXY.y + offset * aXY.x - (bXY.y + offset * bXY.x); } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 3033eacd74f..00eef565394 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -22,7 +22,9 @@ import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; import type {BlocklyOptions} from './blockly_options.js'; import * as browserEvents from './browser_events.js'; +import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js'; import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js'; +import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js'; import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; import {WorkspaceComment} from './comments/workspace_comment.js'; import * as common from './common.js'; @@ -2266,8 +2268,8 @@ export class WorkspaceSvg * * @param comment comment to add. */ - override addTopComment(comment: WorkspaceComment) { - this.addTopBoundedElement(comment as RenderedWorkspaceComment); + override addTopComment(comment: RenderedWorkspaceComment) { + this.addTopBoundedElement(comment); super.addTopComment(comment); } @@ -2276,11 +2278,31 @@ export class WorkspaceSvg * * @param comment comment to remove. */ - override removeTopComment(comment: WorkspaceComment) { - this.removeTopBoundedElement(comment as RenderedWorkspaceComment); + override removeTopComment(comment: RenderedWorkspaceComment) { + this.removeTopBoundedElement(comment); super.removeTopComment(comment); } + /** + * Returns a list of comments on this workspace. + * + * @param ordered If true, sorts the comments based on their position. + * @returns A list of workspace comments. + */ + override getTopComments(ordered = false): RenderedWorkspaceComment[] { + return super.getTopComments(ordered) as RenderedWorkspaceComment[]; + } + + /** + * Returns the workspace comment with the given ID, if any. + * + * @param id The ID of the comment to retrieve. + * @returns The workspace comment with the given ID, or null. + */ + override getCommentById(id: string): RenderedWorkspaceComment | null { + return super.getCommentById(id) as RenderedWorkspaceComment | null; + } + override getRootWorkspace(): WorkspaceSvg | null { return super.getRootWorkspace() as WorkspaceSvg | null; } @@ -2308,8 +2330,15 @@ export class WorkspaceSvg * * @returns The top-level bounded elements. */ - getTopBoundedElements(): IBoundedElement[] { - return new Array().concat(this.topBoundedElements); + getTopBoundedElements(ordered = false): IBoundedElement[] { + const elements = new Array().concat( + this.topBoundedElements, + ); + if (ordered) { + elements.sort(this.sortByOrigin.bind(this)); + } + + return elements; } /** @@ -2794,19 +2823,32 @@ export class WorkspaceSvg return null; } - // Search for a specific workspace comment editor - // (only if id seems like it is one). - const commentEditorIndicator = id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER); - if (commentEditorIndicator !== -1) { - const commentId = id.substring(0, commentEditorIndicator); + // Search for a specific workspace comment or comment icon if the ID + // indicates the presence of one. + const commentIdSeparatorIndex = Math.max( + id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER), + id.indexOf(COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER), + id.indexOf(COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER), + ); + if (commentIdSeparatorIndex !== -1) { + const commentId = id.substring(0, commentIdSeparatorIndex); const comment = this.searchForWorkspaceComment(commentId); if (comment) { - return comment.getEditorFocusableNode(); + if (id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER) > -1) { + return comment.getEditorFocusableNode(); + } else { + return ( + comment.view + .getCommentBarButtons() + .find((button) => button.getFocusableElement().id.includes(id)) ?? + null + ); + } } } // Search for a specific block. - // Don't use `getBlockById` because the block ID is not guaranteeed + // Don't use `getBlockById` because the block ID is not guaranteed // to be globally unique, but the ID on the focusable element is. const block = this.getAllBlocks(false).find( (block) => block.getFocusableElement().id === id, From 5acd072f0519f61848d86b148bdd44537fec9c0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:49:04 -0700 Subject: [PATCH 47/67] chore(deps): bump prettier from 3.6.0 to 3.6.2 (#9185) Bumps [prettier](https://github.com/prettier/prettier) from 3.6.0 to 3.6.2. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.6.0...3.6.2) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.6.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a778f6d83d5..28f9b3bd6c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7717,11 +7717,10 @@ } }, "node_modules/prettier": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.0.tgz", - "integrity": "sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, From 1e37d21f0ae32ef2c7f5bfd0f7e71a3537c8ab77 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Jul 2025 16:07:05 -0700 Subject: [PATCH 48/67] fix: Ensure focus changes when tabbing fields (#9173) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes https://github.com/google/blockly-keyboard-experimentation/issues/578 Fixes part of #8915 ### Proposed Changes Ensures fields update focus to the next field when tabbing between field editors. The old behavior can be observed in [#578](https://github.com/google/blockly-keyboard-experimentation/issues/578) and the new behavior can be observed here: [Screen recording 2025-06-25 1.39.28 PM.webm](https://github.com/user-attachments/assets/e00fcb55-5c20-4d5c-81a8-be9049cc0d7e) ### Reason for Changes Having focus reset back to the original field editor that was opened is an unexpected experience for users. This approach is better. Note that there are some separate changes added here, as well: - Connections and fields now check if their block IDs contain their indicator prefixes since this will all-but-guarantee focus breaks for those nodes. This is an excellent example of why #9171 is needed. - Some minor naming updates for `FieldInput`: it was incorrectly implying that key events are sent for changes to the `input` element used by the field editor, but they are actually `InputEvent`s per https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event. ### Test Coverage New tests were added for field editing in general (since this seems to be missing), including tabbing support to ensure the fixes originally introduced in #9049. One new test has been added specifically for verifying that focus updates with tabbing. This has been verified to fail with the fix removed (as have all tabbing tests with the tabbing code from #9049 removed). Some specific notes for the test changes: - There's a slight test dependency inversion happening here. `FieldInput` is being tested with a specific `FieldNumber` class via real block loading. This isn't ideal, but it seems fine given the circumstances (otherwise a lot of extra setup would be necessary for the tests). - The workspace actually needs to be made visible during tests in order for focus to work correctly (though it's reset at the end of each test, but this may cause some flickering while the tests are running). - It's the case that a bunch of tests were actually setting up blocks incorrectly (i.e. not defining a must-have `id` property which caused some issues with the new field and connection ID validation checks). These tests have been corrected, but it's worth noting that the blocks are likely still technically wrong since they are not conforming to their TypeScript contracts. - Similar to the previous point, one test was incorrectly setting the first ID to be returned by the ID generator as `undefined` since (presumably due to a copy-and-paste error when the test was introduced) it was referencing a `TEST_BLOCK_ID` property that hadn't been defined for that test suite. This has been corrected as, without it, there are failures due to the new validation checks. - For the connection database checks, a new ID is generated instead of fixing the block ID to ensure that it's always unique even if called multiple times (otherwise a block ID would need to be piped through from the calling tests, or an invalid situation would need to be introduced where multiple blocks shared an ID; the former seemed unnecessary and the latter seemed nonideal). - There are distinct Geras/Zelos tests to validate the case where a full-block field should have its parent block, rather than the field itself, focused on tabbing. See this conversation for more context: https://github.com/google/blockly/pull/9173#discussion_r2172921455. ### Documentation No documentation changes should be needed here. ### Additional Information Nothing to add. --- core/connection.ts | 6 + core/field.ts | 6 + core/field_input.ts | 23 +- tests/mocha/connection_checker_test.js | 47 +++- tests/mocha/connection_db_test.js | 3 +- tests/mocha/event_test.js | 1 + tests/mocha/field_textinput_test.js | 296 +++++++++++++++++++++++++ 7 files changed, 364 insertions(+), 18 deletions(-) diff --git a/core/connection.ts b/core/connection.ts index fbd094dba69..a79b7b9b143 100644 --- a/core/connection.ts +++ b/core/connection.ts @@ -83,6 +83,12 @@ export class Connection { public type: number, ) { this.sourceBlock_ = source; + if (source.id.includes('_connection')) { + throw new Error( + `Connection ID indicator is contained in block ID. This will cause ` + + `problems with focus: ${source.id}.`, + ); + } this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`; } diff --git a/core/field.ts b/core/field.ts index c4b6514785e..fdcb2d693b9 100644 --- a/core/field.ts +++ b/core/field.ts @@ -265,6 +265,12 @@ export abstract class Field throw Error('Field already bound to a block'); } this.sourceBlock_ = block; + if (block.id.includes('_field')) { + throw new Error( + `Field ID indicator is contained in block ID. This may cause ` + + `problems with focus: ${block.id}.`, + ); + } this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } diff --git a/core/field_input.ts b/core/field_input.ts index c7921d6f015..b685309183a 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -27,6 +27,7 @@ import { FieldValidator, UnattachedFieldError, } from './field.js'; +import {getFocusManager} from './focus_manager.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; @@ -83,8 +84,8 @@ export abstract class FieldInput extends Field< /** Key down event data. */ private onKeyDownWrapper: browserEvents.Data | null = null; - /** Key input event data. */ - private onKeyInputWrapper: browserEvents.Data | null = null; + /** Input element input event data. */ + private onInputWrapper: browserEvents.Data | null = null; /** * Whether the field should consider the whole parent block to be its click @@ -558,7 +559,7 @@ export abstract class FieldInput extends Field< this.onHtmlInputKeyDown_, ); // Resize after every input change. - this.onKeyInputWrapper = browserEvents.conditionalBind( + this.onInputWrapper = browserEvents.conditionalBind( htmlInput, 'input', this, @@ -572,9 +573,9 @@ export abstract class FieldInput extends Field< browserEvents.unbind(this.onKeyDownWrapper); this.onKeyDownWrapper = null; } - if (this.onKeyInputWrapper) { - browserEvents.unbind(this.onKeyInputWrapper); - this.onKeyInputWrapper = null; + if (this.onInputWrapper) { + browserEvents.unbind(this.onInputWrapper); + this.onInputWrapper = null; } } @@ -614,6 +615,14 @@ export abstract class FieldInput extends Field< if (target instanceof FieldInput) { WidgetDiv.hideIfOwner(this); dropDownDiv.hideWithoutAnimation(); + const targetSourceBlock = target.getSourceBlock(); + if ( + target.isFullBlockField() && + targetSourceBlock && + targetSourceBlock instanceof BlockSvg + ) { + getFocusManager().focusNode(targetSourceBlock); + } else getFocusManager().focusNode(target); target.showEditor(); } } @@ -622,7 +631,7 @@ export abstract class FieldInput extends Field< /** * Handle a change to the editor. * - * @param _e Keyboard event. + * @param _e InputEvent. */ private onHtmlInputChange(_e: Event) { // Intermediate value changes from user input are not confirmed until the diff --git a/tests/mocha/connection_checker_test.js b/tests/mocha/connection_checker_test.js index f353a2b77c2..fee2966d766 100644 --- a/tests/mocha/connection_checker_test.js +++ b/tests/mocha/connection_checker_test.js @@ -29,7 +29,10 @@ suite('Connection checker', function () { } test('Target Null', function () { - const connection = new Blockly.Connection({}, ConnectionType.INPUT_VALUE); + const connection = new Blockly.Connection( + {id: 'test'}, + ConnectionType.INPUT_VALUE, + ); assertReasonHelper( this.checker, connection, @@ -38,7 +41,7 @@ suite('Connection checker', function () { ); }); test('Target Self', function () { - const block = {workspace: 1}; + const block = {id: 'test', workspace: 1}; const connection1 = new Blockly.Connection( block, ConnectionType.INPUT_VALUE, @@ -57,11 +60,11 @@ suite('Connection checker', function () { }); test('Different Workspaces', function () { const connection1 = new Blockly.Connection( - {workspace: 1}, + {id: 'test1', workspace: 1}, ConnectionType.INPUT_VALUE, ); const connection2 = new Blockly.Connection( - {workspace: 2}, + {id: 'test2', workspace: 2}, ConnectionType.OUTPUT_VALUE, ); @@ -76,10 +79,10 @@ suite('Connection checker', function () { setup(function () { // We have to declare each separately so that the connections belong // on different blocks. - const prevBlock = {isShadow: function () {}}; - const nextBlock = {isShadow: function () {}}; - const outBlock = {isShadow: function () {}}; - const inBlock = {isShadow: function () {}}; + const prevBlock = {id: 'test1', isShadow: function () {}}; + const nextBlock = {id: 'test2', isShadow: function () {}}; + const outBlock = {id: 'test3', isShadow: function () {}}; + const inBlock = {id: 'test4', isShadow: function () {}}; this.previous = new Blockly.Connection( prevBlock, ConnectionType.PREVIOUS_STATEMENT, @@ -197,11 +200,13 @@ suite('Connection checker', function () { suite('Shadows', function () { test('Previous Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return true; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return false; }, @@ -224,11 +229,13 @@ suite('Connection checker', function () { }); test('Next Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return false; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -251,11 +258,13 @@ suite('Connection checker', function () { }); test('Prev and Next Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return true; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -278,11 +287,13 @@ suite('Connection checker', function () { }); test('Output Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return true; }, }; const inBlock = { + id: 'test2', isShadow: function () { return false; }, @@ -305,11 +316,13 @@ suite('Connection checker', function () { }); test('Input Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return false; }, }; const inBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -332,11 +345,13 @@ suite('Connection checker', function () { }); test('Output and Input Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return true; }, }; const inBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -373,9 +388,11 @@ suite('Connection checker', function () { }; test('Output connected, adding previous', function () { const outBlock = { + id: 'test1', isShadow: function () {}, }; const inBlock = { + id: 'test2', isShadow: function () {}, }; const outCon = new Blockly.Connection( @@ -394,6 +411,7 @@ suite('Connection checker', function () { ConnectionType.PREVIOUS_STATEMENT, ); const nextBlock = { + id: 'test3', isShadow: function () {}, }; const nextCon = new Blockly.Connection( @@ -410,9 +428,11 @@ suite('Connection checker', function () { }); test('Previous connected, adding output', function () { const prevBlock = { + id: 'test1', isShadow: function () {}, }; const nextBlock = { + id: 'test2', isShadow: function () {}, }; const prevCon = new Blockly.Connection( @@ -431,6 +451,7 @@ suite('Connection checker', function () { ConnectionType.OUTPUT_VALUE, ); const inBlock = { + id: 'test3', isShadow: function () {}, }; const inCon = new Blockly.Connection( @@ -449,8 +470,14 @@ suite('Connection checker', function () { }); suite('Check Types', function () { setup(function () { - this.con1 = new Blockly.Connection({}, ConnectionType.PREVIOUS_STATEMENT); - this.con2 = new Blockly.Connection({}, ConnectionType.NEXT_STATEMENT); + this.con1 = new Blockly.Connection( + {id: 'test1'}, + ConnectionType.PREVIOUS_STATEMENT, + ); + this.con2 = new Blockly.Connection( + {id: 'test2'}, + ConnectionType.NEXT_STATEMENT, + ); }); function assertCheckTypes(checker, one, two) { assert.isTrue(checker.doTypeChecks(one, two)); diff --git a/tests/mocha/connection_db_test.js b/tests/mocha/connection_db_test.js index e7f397d545f..04f685124ca 100644 --- a/tests/mocha/connection_db_test.js +++ b/tests/mocha/connection_db_test.js @@ -5,6 +5,7 @@ */ import {ConnectionType} from '../../build/src/core/connection_type.js'; +import * as idGenerator from '../../build/src/core/utils/idgenerator.js'; import {assert} from '../../node_modules/chai/chai.js'; import { sharedTestSetup, @@ -31,7 +32,7 @@ suite('Connection Database', function () { }; workspace.connectionDBList[type] = opt_database || this.database; const connection = new Blockly.RenderedConnection( - {workspace: workspace}, + {id: idGenerator.getNextUniqueId(), workspace: workspace}, type, ); connection.x = x; diff --git a/tests/mocha/event_test.js b/tests/mocha/event_test.js index 00d704ff052..7423f22f74b 100644 --- a/tests/mocha/event_test.js +++ b/tests/mocha/event_test.js @@ -355,6 +355,7 @@ suite('Events', function () { suite('With variable getter blocks', function () { setup(function () { + this.TEST_BLOCK_ID = 'test_block_id'; this.genUidStub = createGenUidStubWithReturns([ this.TEST_BLOCK_ID, 'test_var_id', diff --git a/tests/mocha/field_textinput_test.js b/tests/mocha/field_textinput_test.js index 82c1a645e6d..7dc105f72f0 100644 --- a/tests/mocha/field_textinput_test.js +++ b/tests/mocha/field_textinput_test.js @@ -294,4 +294,300 @@ suite('Text Input Fields', function () { this.assertValue('test text'); }); }); + + suite('Use editor', function () { + setup(function () { + this.blockJson = { + 'type': 'math_arithmetic', + 'id': 'test_arithmetic_block', + 'fields': { + 'OP': 'ADD', + }, + 'inputs': { + 'A': { + 'shadow': { + 'type': 'math_number', + 'id': 'left_input_block', + 'name': 'test_name', + 'fields': { + 'NUM': 1, + }, + }, + }, + 'B': { + 'shadow': { + 'type': 'math_number', + 'id': 'right_input_block', + 'fields': { + 'NUM': 2, + }, + }, + }, + }, + }; + + this.getFieldFromShadowBlock = function (shadowBlock) { + return shadowBlock.getFields().next().value; + }; + + this.simulateTypingIntoInput = (inputElem, newText) => { + // Typing into an input field changes its value directly and then fires + // an InputEvent (which FieldInput relies on to automatically + // synchronize its state). + inputElem.value = newText; + inputElem.dispatchEvent(new InputEvent('input')); + }; + }); + + // The block being tested doesn't use full-block fields in Geras. + suite('Geras theme', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + Blockly.serialization.blocks.append(this.blockJson, this.workspace); + + // The workspace actually needs to be visible for focus. + document.getElementById('blocklyDiv').style.visibility = 'visible'; + }); + teardown(function () { + document.getElementById('blocklyDiv').style.visibility = 'hidden'; + workspaceTeardown.call(this, this.workspace); + }); + + test('No editor open by default', function () { + // The editor is only opened if its indicated that it should be open. + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Type in editor with escape does not change field value', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, 'updated value'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + }), + ); + + // 'Escape' will avoid saving the edited field value and close the editor. + assert.equal(field.getValue(), 1); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Type in editor with enter changes field value', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // 'Enter' will save the edited result and close the editor. + assert.equal(field.getValue(), 10); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Not finishing editing does not return ephemeral focus', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + Blockly.getFocusManager().focusNode(field); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + + // If the editor doesn't restore focus then the current focused element is + // still the editor. + assert.strictEqual(document.activeElement, fieldEditor); + }); + + test('Finishing editing returns ephemeral focus', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + Blockly.getFocusManager().focusNode(field); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + }), + ); + + // Verify that exiting the editor restores focus back to the field. + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), field); + assert.strictEqual(document.activeElement, field.getFocusableElement()); + }); + + test('Opening an editor, tabbing, then editing changes the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that only the right field changed (due to the tab). + assert.equal(leftField.getValue(), 1); + assert.equal(rightField.getValue(), 15); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Opening an editor, tabbing, then editing changes focus to the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that the tab causes focus to change to the right field. + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + rightField, + ); + assert.strictEqual( + document.activeElement, + rightField.getFocusableElement(), + ); + }); + }); + + // The block being tested uses full-block fields in Zelos. + suite('Zelos theme', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'zelos', + }); + Blockly.serialization.blocks.append(this.blockJson, this.workspace); + + // The workspace actually needs to be visible for focus. + document.getElementById('blocklyDiv').style.visibility = 'visible'; + }); + teardown(function () { + document.getElementById('blocklyDiv').style.visibility = 'hidden'; + workspaceTeardown.call(this, this.workspace); + }); + + test('Opening an editor, tabbing, then editing full block field changes the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that only the right field changed (due to the tab). + assert.equal(leftField.getValue(), 1); + assert.equal(rightField.getValue(), 15); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Opening an editor, tabbing, then editing full block field changes focus to the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + Blockly.getFocusManager().focusNode(leftInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that the tab causes focus to change to the right field block. + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + rightInputBlock, + ); + assert.strictEqual( + document.activeElement, + rightInputBlock.getFocusableElement(), + ); + }); + }); + }); }); From 4c78c1d4a31fd2474737a3068c11d11881ae954e Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Jul 2025 16:11:50 -0700 Subject: [PATCH 49/67] fix: Auto close drop-down divs on lost focus (#9175) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes https://github.com/google/blockly-keyboard-experimentation/issues/563 ### Proposed Changes This introduces support in `FocusManager` to receive feedback on when an ephemerally focused element entirely loses focus (that is, neither it nor any of its descendants have focus). This also introduces a behavior change for drop-down divs using the previously mentioned functionality to automatically close themselves when they lose focus for any reason (e.g. clicking outside of the div or tab navigating away from it). Finally, and **importantly**, this adds a case where ephemeral focus does _not_ automatically return to the previously focused node: when focus is lost to the ephemerally focused element's tree and isn't instead put on another focused node. ### Reason for Changes Ultimately, focus is probably the best proxy for cases when a drop-down div ought to no longer be open. However, tracking focus only within the scope of the drop-down div utility is rather difficult since a lot of the same problems that `FocusManager` handles also occur here (with regards to both descendants and outside elements receiving focus). It made more sense to expand `FocusManager`'s ephemeral focus support: - It was easier to implement this `FocusManager` and in a way that's much more robust (since it's leveraging existing event handlers). - Using `FocusManager` trivialized the solution for drop-down divs. - There could be other use cases where custom ephemeral focus uses might benefit from knowing when they lose focus. This new support is enabled by default for all drop-down divs, but can be disabled by callers if they wish to revert to the previous behavior of not auto-closing. The change for whether to restore ephemeral focus was needed to fix a drawback that arises from the automatic returning of ephemeral focus introduced in this PR: when a user clicks out of an open drop-down menu it will restore focus back to the node that held focus prior to taking ephemeral focus (since it properly hides the drop-down div and restores focus). This creates awkward behavior issues for both mouse and keyboard users: - For mouse: trying to open a drop-down outside of Blockly will automatically close the drop-down when the Blockly drop-down finishes closing (since focus is stolen back away from the thing the user clicked on). - For keyboard: tab navigating out of Blockly tries to force focus back to Blockly. ### Test Coverage New tests have been added for both the drop-down div and `FocusManager` components, and have been verified as failing without the new behaviors in place. There may be other edge cases worth testing for `FocusManager` in particular, but the tests introduced in this PR seem to cover the most important cases. Demonstration of the new behavior: [Screen recording 2025-07-01 6.28.37 PM.webm](https://github.com/user-attachments/assets/7af29fed-1ba1-4828-a6cd-65bb94509e72) ### Documentation No new documentation changes seem needed beyond the code documentation updates. ### Additional Information It's also possible to change the automatic restoration behavior to be conditional instead of always assuming focus shouldn't be reset if focus leaves the ephemeral element, but that's probably a better change if there's an actual user issue discovered with this approach. --- core/dropdowndiv.ts | 36 ++++++- core/focus_manager.ts | 90 ++++++++++++++-- tests/mocha/dropdowndiv_test.js | 72 ++++++++++++- tests/mocha/focus_manager_test.js | 167 ++++++++++++++++++++++++++++++ tests/mocha/index.html | 8 +- 5 files changed, 359 insertions(+), 14 deletions(-) diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index ceab467a895..608fe9b5b2c 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -213,6 +213,8 @@ export function setColour(backgroundColour: string, borderColour: string) { * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByBlock( @@ -221,11 +223,13 @@ export function showPositionedByBlock( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, + autoCloseOnLostFocus: boolean = true, ): boolean { return showPositionedByRect( getScaledBboxOfBlock(block), field as Field, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -245,6 +249,8 @@ export function showPositionedByBlock( * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByField( @@ -252,12 +258,14 @@ export function showPositionedByField( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, + autoCloseOnLostFocus: boolean = true, ): boolean { positionToField = true; return showPositionedByRect( getScaledBboxOfField(field as Field), field as Field, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -302,12 +310,15 @@ function getScaledBboxOfField(field: Field): Rect { * according to the drop-down div's lifetime. Note that if a false value is * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( bBox: Rect, field: Field, manageEphemeralFocus: boolean, + autoCloseOnLostFocus: boolean, opt_onHide?: () => void, opt_secondaryYOffset?: number, ): boolean { @@ -335,6 +346,7 @@ function showPositionedByRect( secondaryX, secondaryY, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, ); } @@ -357,6 +369,8 @@ function showPositionedByRect( * @param opt_onHide Optional callback for when the drop-down is hidden. * @param manageEphemeralFocus Whether ephemeral focus should be managed * according to the widget div's lifetime. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered at the primary origin point. * @internal */ @@ -368,6 +382,7 @@ export function show( secondaryX: number, secondaryY: number, manageEphemeralFocus: boolean, + autoCloseOnLostFocus: boolean, opt_onHide?: () => void, ): boolean { owner = newOwner as Field; @@ -394,7 +409,18 @@ export function show( // Ephemeral focus must happen after the div is fully visible in order to // ensure that it properly receives focus. if (manageEphemeralFocus) { - returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + const autoCloseCallback = autoCloseOnLostFocus + ? (hasFocus: boolean) => { + // If focus is ever lost, close the drop-down. + if (!hasFocus) { + hide(); + } + } + : null; + returnEphemeralFocus = getFocusManager().takeEphemeralFocus( + div, + autoCloseCallback, + ); } return atOrigin; @@ -693,7 +719,6 @@ export function hideWithoutAnimation() { onHide(); onHide = null; } - clearContent(); owner = null; (common.getMainWorkspace() as WorkspaceSvg).markFocused(); @@ -702,6 +727,13 @@ export function hideWithoutAnimation() { returnEphemeralFocus(); returnEphemeralFocus = null; } + + // Content must be cleared after returning ephemeral focus since otherwise it + // may force focus changes which could desynchronize the focus manager and + // make it think the user directed focus away from the drop-down div (which + // will then notify it to not restore focus back to any previously focused + // node). + clearContent(); } /** diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 02e0591070f..31453b827b5 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -17,6 +17,14 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; */ export type ReturnEphemeralFocus = () => void; +/** + * Type declaration for an optional callback to observe when an element with + * ephemeral focus has its DOM focus changed before ephemeral focus is returned. + * + * See FocusManager.takeEphemeralFocus for more details. + */ +export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void; + /** * Represents an IFocusableTree that has been registered for focus management in * FocusManager. @@ -78,7 +86,10 @@ export class FocusManager { private previouslyFocusedNode: IFocusableNode | null = null; private registeredTrees: Array = []; - private currentlyHoldsEphemeralFocus: boolean = false; + private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null; + private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null = + null; + private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false; private lockFocusStateChanges: boolean = false; private recentlyLostAllFocus: boolean = false; private isUpdatingFocusedNode: boolean = false; @@ -118,6 +129,21 @@ export class FocusManager { } else { this.defocusCurrentFocusedNode(); } + + const ephemeralFocusElem = this.ephemerallyFocusedElement; + if (ephemeralFocusElem) { + const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus; + const hasFocus = + !!element && + element instanceof Node && + ephemeralFocusElem.contains(element); + if (hadFocus !== hasFocus) { + if (this.ephemeralDomFocusChangedCallback) { + this.ephemeralDomFocusChangedCallback(hasFocus); + } + this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus; + } + } }; // Register root document focus listeners for tracking when focus leaves all @@ -313,7 +339,7 @@ export class FocusManager { */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus; + const mustRestoreUpdatingNode = !this.ephemerallyFocusedElement; if (mustRestoreUpdatingNode) { // Disable state syncing from DOM events since possible calls to focus() // below will loop a call back to focusNode(). @@ -395,7 +421,7 @@ export class FocusManager { this.removeHighlight(nextTreeRoot); } - if (!this.currentlyHoldsEphemeralFocus) { + if (!this.ephemerallyFocusedElement) { // Only change the actively focused node if ephemeral state isn't held. this.activelyFocusNode(nodeToFocus, prevTree ?? null); } @@ -423,24 +449,50 @@ export class FocusManager { * the returned lambda is called. Additionally, only 1 ephemeral focus context * can be active at any given time (attempting to activate more than one * simultaneously will result in an error being thrown). + * + * Important details regarding the onFocusChangedInDom callback: + * - This method will be called initially with a value of 'true' indicating + * that the ephemeral element has been focused, so callers can rely on that, + * if needed, for initialization logic. + * - It's safe to end ephemeral focus in this callback (and is encouraged for + * callers that wish to automatically end ephemeral focus when the user + * directs focus outside of the element). + * - The element AND all of its descendants are tracked for focus. That means + * the callback will ONLY be called with a value of 'false' if focus + * completely leaves the DOM tree for the provided focusable element. + * - It's invalid to return focus on the very first call to the callback, + * however this is expected to be impossible, anyway, since this method + * won't return until after the first call to the callback (thus there will + * be no means to return ephemeral focus). + * + * @param focusableElement The element that should be focused until returned. + * @param onFocusChangedInDom An optional callback which will be notified + * whenever the provided element's focus changes before ephemeral focus is + * returned. See the details above for specifics. + * @returns A ReturnEphemeralFocus that must be called when ephemeral focus + * should end. */ takeEphemeralFocus( focusableElement: HTMLElement | SVGElement, + onFocusChangedInDom: EphemeralFocusChangedInDom | null = null, ): ReturnEphemeralFocus { this.ensureManagerIsUnlocked(); - if (this.currentlyHoldsEphemeralFocus) { + if (this.ephemerallyFocusedElement) { throw Error( `Attempted to take ephemeral focus when it's already held, ` + `with new element: ${focusableElement}.`, ); } - this.currentlyHoldsEphemeralFocus = true; + this.ephemerallyFocusedElement = focusableElement; + this.ephemeralDomFocusChangedCallback = onFocusChangedInDom; if (this.focusedNode) { this.passivelyFocusNode(this.focusedNode, null); } focusableElement.focus(); + this.ephemerallyFocusedElementCurrentlyHasFocus = true; + const focusedNodeAtStart = this.focusedNode; let hasFinishedEphemeralFocus = false; return () => { if (hasFinishedEphemeralFocus) { @@ -450,9 +502,22 @@ export class FocusManager { ); } hasFinishedEphemeralFocus = true; - this.currentlyHoldsEphemeralFocus = false; - - if (this.focusedNode) { + this.ephemerallyFocusedElement = null; + this.ephemeralDomFocusChangedCallback = null; + + const hadEphemeralFocusAtEnd = + this.ephemerallyFocusedElementCurrentlyHasFocus; + this.ephemerallyFocusedElementCurrentlyHasFocus = false; + + // If the user forced away DOM focus during ephemeral focus, then + // determine whether focus should be restored back to a focusable node + // after ephemeral focus ends. Generally it shouldn't be, but in some + // cases (such as the user focusing an actual focusable node) it then + // should be. + const hasNewFocusedNode = focusedNodeAtStart !== this.focusedNode; + const shouldRestoreToNode = hasNewFocusedNode || hadEphemeralFocusAtEnd; + + if (this.focusedNode && shouldRestoreToNode) { this.activelyFocusNode(this.focusedNode, null); // Even though focus was restored, check if it's lost again. It's @@ -470,6 +535,11 @@ export class FocusManager { this.focusNode(capturedNode); } }, 0); + } else { + // If the ephemeral element lost focus then do not force it back since + // that likely will override the user's own attempt to move focus away + // from the ephemeral experience. + this.defocusCurrentFocusedNode(); } }; } @@ -478,7 +548,7 @@ export class FocusManager { * @returns whether something is currently holding ephemeral focus */ ephemeralFocusTaken(): boolean { - return this.currentlyHoldsEphemeralFocus; + return !!this.ephemerallyFocusedElement; } /** @@ -516,7 +586,7 @@ export class FocusManager { // The current node will likely be defocused while ephemeral focus is held, // but internal manager state shouldn't change since the node should be // restored upon exiting ephemeral focus mode. - if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) { + if (this.focusedNode && !this.ephemerallyFocusedElement) { this.passivelyFocusNode(this.focusedNode, null); this.updateFocusedNode(null); } diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index fc792fbaf24..fac8368a952 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -155,7 +155,7 @@ suite('DropDownDiv', function () { }); test('Escape dismisses DropDownDiv', function () { let hidden = false; - Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, () => { + Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, false, () => { hidden = true; }); assert.isFalse(hidden); @@ -252,6 +252,34 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); + + test('without auto close on lost focus lost focus does not hide drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, true, false); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // Even though the drop-down lost focus, it should still be visible. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + }); + + test('with auto close on lost focus lost focus hides drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, true, true); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // the drop-down should now be hidden since it lost focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); }); suite('showPositionedByBlock()', function () { @@ -325,6 +353,48 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); + + test('without auto close on lost focus lost focus does not hide drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + true, + false, + ); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // Even though the drop-down lost focus, it should still be visible. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + }); + + test('with auto close on lost focus lost focus hides drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + true, + true, + ); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // the drop-down should now be hidden since it lost focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); }); suite('hideWithoutAnimation()', function () { diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index 26dcb8dbe68..cb4a43652fe 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -5975,5 +5975,172 @@ suite('FocusManager', function () { ); assert.strictEqual(document.activeElement, nodeElem); }); + + test('with focus change callback initially calls focus change callback with initial state', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + + assert.strictEqual(callback.callCount, 1); + assert.isTrue(callback.firstCall.calledWithExactly(true)); + }); + + test('with focus change callback finishes ephemeral does not calls focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const finishFocusCallback = this.focusManager.takeEphemeralFocus( + ephemeralElement, + callback, + ); + callback.resetHistory(); + + finishFocusCallback(); + + assert.isFalse(callback.called); + }); + + test('with focus change callback set focus to ephemeral child does not call focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElementChild = document.getElementById( + 'nonTreeElementForEphemeralFocus.child1', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + callback.resetHistory(); + + ephemeralElementChild.focus(); + + // Focusing a child element shouldn't invoke the callback since the + // ephemeral element's tree still holds focus. + assert.isFalse(callback.called); + }); + + test('with focus change callback set focus to non-ephemeral element calls focus change callback', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + + ephemeralElement2.focus(); + + // There should be a second call that indicates focus was lost. + assert.strictEqual(callback.callCount, 2); + assert.isTrue(callback.secondCall.calledWithExactly(false)); + }); + + test('with focus change callback set focus to non-ephemeral element then back calls focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElementChild = document.getElementById( + 'nonTreeElementForEphemeralFocus.child1', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + ephemeralElement2.focus(); + + ephemeralElementChild.focus(); + + // The latest call should be returning focus. + assert.strictEqual(callback.callCount, 3); + assert.isTrue(callback.thirdCall.calledWithExactly(true)); + }); + + test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + const finishFocusCallback = this.focusManager.takeEphemeralFocus( + ephemeralElement, + (hasFocus) => { + if (!hasFocus) finishFocusCallback(); + }, + ); + + // Force focus away, triggering the callback's automatic returning logic. + ephemeralElement2.focus(); + + // The original focused node should be restored. + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(activeElems.length, 1); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, nodeElem); + }); + + test('with focus on non-ephemeral element ephemeral ended does not restore to focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + // Force focus away, triggering the callback's automatic returning logic. + ephemeralElement2.focus(); + + finishFocusCallback(); + + // The original node should not be focused since the ephemeral element + // lost its own DOM focus while ephemeral focus was active. Instead, the + // newly active element should still hold focus. + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + const passiveElems = Array.from( + document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + assert.strictEqual(passiveElems.length, 1); + assert.includesClass( + this.testFocusableTree2Node1.getFocusableElement().classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, ephemeralElement2); + assert.isFalse(this.focusManager.ephemeralFocusTaken()); + }); }); }); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 208c2995596..fea0fb18e84 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -94,7 +94,13 @@
Unfocusable element
-
+
+
+
+
From 7ad18f717a0253aabc0d2fb1dd5bb3199ff0bc9b Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Mon, 7 Jul 2025 17:40:58 +0100 Subject: [PATCH 50/67] Revert "fix: Auto close drop-down divs on lost focus (#9175)" (#9204) This reverts commit 4c78c1d4a31fd2474737a3068c11d11881ae954e / PR #9175. --- core/dropdowndiv.ts | 36 +------ core/focus_manager.ts | 90 ++-------------- tests/mocha/dropdowndiv_test.js | 72 +------------ tests/mocha/focus_manager_test.js | 167 ------------------------------ tests/mocha/index.html | 8 +- 5 files changed, 14 insertions(+), 359 deletions(-) diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index 608fe9b5b2c..ceab467a895 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -213,8 +213,6 @@ export function setColour(backgroundColour: string, borderColour: string) { * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. - * @param autoCloseOnLostFocus Whether the drop-down should automatically hide - * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByBlock( @@ -223,13 +221,11 @@ export function showPositionedByBlock( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, - autoCloseOnLostFocus: boolean = true, ): boolean { return showPositionedByRect( getScaledBboxOfBlock(block), field as Field, manageEphemeralFocus, - autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -249,8 +245,6 @@ export function showPositionedByBlock( * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. - * @param autoCloseOnLostFocus Whether the drop-down should automatically hide - * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByField( @@ -258,14 +252,12 @@ export function showPositionedByField( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, - autoCloseOnLostFocus: boolean = true, ): boolean { positionToField = true; return showPositionedByRect( getScaledBboxOfField(field as Field), field as Field, manageEphemeralFocus, - autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -310,15 +302,12 @@ function getScaledBboxOfField(field: Field): Rect { * according to the drop-down div's lifetime. Note that if a false value is * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. - * @param autoCloseOnLostFocus Whether the drop-down should automatically hide - * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( bBox: Rect, field: Field, manageEphemeralFocus: boolean, - autoCloseOnLostFocus: boolean, opt_onHide?: () => void, opt_secondaryYOffset?: number, ): boolean { @@ -346,7 +335,6 @@ function showPositionedByRect( secondaryX, secondaryY, manageEphemeralFocus, - autoCloseOnLostFocus, opt_onHide, ); } @@ -369,8 +357,6 @@ function showPositionedByRect( * @param opt_onHide Optional callback for when the drop-down is hidden. * @param manageEphemeralFocus Whether ephemeral focus should be managed * according to the widget div's lifetime. - * @param autoCloseOnLostFocus Whether the drop-down should automatically hide - * if it loses DOM focus for any reason. * @returns True if the menu rendered at the primary origin point. * @internal */ @@ -382,7 +368,6 @@ export function show( secondaryX: number, secondaryY: number, manageEphemeralFocus: boolean, - autoCloseOnLostFocus: boolean, opt_onHide?: () => void, ): boolean { owner = newOwner as Field; @@ -409,18 +394,7 @@ export function show( // Ephemeral focus must happen after the div is fully visible in order to // ensure that it properly receives focus. if (manageEphemeralFocus) { - const autoCloseCallback = autoCloseOnLostFocus - ? (hasFocus: boolean) => { - // If focus is ever lost, close the drop-down. - if (!hasFocus) { - hide(); - } - } - : null; - returnEphemeralFocus = getFocusManager().takeEphemeralFocus( - div, - autoCloseCallback, - ); + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); } return atOrigin; @@ -719,6 +693,7 @@ export function hideWithoutAnimation() { onHide(); onHide = null; } + clearContent(); owner = null; (common.getMainWorkspace() as WorkspaceSvg).markFocused(); @@ -727,13 +702,6 @@ export function hideWithoutAnimation() { returnEphemeralFocus(); returnEphemeralFocus = null; } - - // Content must be cleared after returning ephemeral focus since otherwise it - // may force focus changes which could desynchronize the focus manager and - // make it think the user directed focus away from the drop-down div (which - // will then notify it to not restore focus back to any previously focused - // node). - clearContent(); } /** diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 31453b827b5..02e0591070f 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -17,14 +17,6 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; */ export type ReturnEphemeralFocus = () => void; -/** - * Type declaration for an optional callback to observe when an element with - * ephemeral focus has its DOM focus changed before ephemeral focus is returned. - * - * See FocusManager.takeEphemeralFocus for more details. - */ -export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void; - /** * Represents an IFocusableTree that has been registered for focus management in * FocusManager. @@ -86,10 +78,7 @@ export class FocusManager { private previouslyFocusedNode: IFocusableNode | null = null; private registeredTrees: Array = []; - private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null; - private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null = - null; - private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false; + private currentlyHoldsEphemeralFocus: boolean = false; private lockFocusStateChanges: boolean = false; private recentlyLostAllFocus: boolean = false; private isUpdatingFocusedNode: boolean = false; @@ -129,21 +118,6 @@ export class FocusManager { } else { this.defocusCurrentFocusedNode(); } - - const ephemeralFocusElem = this.ephemerallyFocusedElement; - if (ephemeralFocusElem) { - const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus; - const hasFocus = - !!element && - element instanceof Node && - ephemeralFocusElem.contains(element); - if (hadFocus !== hasFocus) { - if (this.ephemeralDomFocusChangedCallback) { - this.ephemeralDomFocusChangedCallback(hasFocus); - } - this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus; - } - } }; // Register root document focus listeners for tracking when focus leaves all @@ -339,7 +313,7 @@ export class FocusManager { */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - const mustRestoreUpdatingNode = !this.ephemerallyFocusedElement; + const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus; if (mustRestoreUpdatingNode) { // Disable state syncing from DOM events since possible calls to focus() // below will loop a call back to focusNode(). @@ -421,7 +395,7 @@ export class FocusManager { this.removeHighlight(nextTreeRoot); } - if (!this.ephemerallyFocusedElement) { + if (!this.currentlyHoldsEphemeralFocus) { // Only change the actively focused node if ephemeral state isn't held. this.activelyFocusNode(nodeToFocus, prevTree ?? null); } @@ -449,50 +423,24 @@ export class FocusManager { * the returned lambda is called. Additionally, only 1 ephemeral focus context * can be active at any given time (attempting to activate more than one * simultaneously will result in an error being thrown). - * - * Important details regarding the onFocusChangedInDom callback: - * - This method will be called initially with a value of 'true' indicating - * that the ephemeral element has been focused, so callers can rely on that, - * if needed, for initialization logic. - * - It's safe to end ephemeral focus in this callback (and is encouraged for - * callers that wish to automatically end ephemeral focus when the user - * directs focus outside of the element). - * - The element AND all of its descendants are tracked for focus. That means - * the callback will ONLY be called with a value of 'false' if focus - * completely leaves the DOM tree for the provided focusable element. - * - It's invalid to return focus on the very first call to the callback, - * however this is expected to be impossible, anyway, since this method - * won't return until after the first call to the callback (thus there will - * be no means to return ephemeral focus). - * - * @param focusableElement The element that should be focused until returned. - * @param onFocusChangedInDom An optional callback which will be notified - * whenever the provided element's focus changes before ephemeral focus is - * returned. See the details above for specifics. - * @returns A ReturnEphemeralFocus that must be called when ephemeral focus - * should end. */ takeEphemeralFocus( focusableElement: HTMLElement | SVGElement, - onFocusChangedInDom: EphemeralFocusChangedInDom | null = null, ): ReturnEphemeralFocus { this.ensureManagerIsUnlocked(); - if (this.ephemerallyFocusedElement) { + if (this.currentlyHoldsEphemeralFocus) { throw Error( `Attempted to take ephemeral focus when it's already held, ` + `with new element: ${focusableElement}.`, ); } - this.ephemerallyFocusedElement = focusableElement; - this.ephemeralDomFocusChangedCallback = onFocusChangedInDom; + this.currentlyHoldsEphemeralFocus = true; if (this.focusedNode) { this.passivelyFocusNode(this.focusedNode, null); } focusableElement.focus(); - this.ephemerallyFocusedElementCurrentlyHasFocus = true; - const focusedNodeAtStart = this.focusedNode; let hasFinishedEphemeralFocus = false; return () => { if (hasFinishedEphemeralFocus) { @@ -502,22 +450,9 @@ export class FocusManager { ); } hasFinishedEphemeralFocus = true; - this.ephemerallyFocusedElement = null; - this.ephemeralDomFocusChangedCallback = null; - - const hadEphemeralFocusAtEnd = - this.ephemerallyFocusedElementCurrentlyHasFocus; - this.ephemerallyFocusedElementCurrentlyHasFocus = false; - - // If the user forced away DOM focus during ephemeral focus, then - // determine whether focus should be restored back to a focusable node - // after ephemeral focus ends. Generally it shouldn't be, but in some - // cases (such as the user focusing an actual focusable node) it then - // should be. - const hasNewFocusedNode = focusedNodeAtStart !== this.focusedNode; - const shouldRestoreToNode = hasNewFocusedNode || hadEphemeralFocusAtEnd; - - if (this.focusedNode && shouldRestoreToNode) { + this.currentlyHoldsEphemeralFocus = false; + + if (this.focusedNode) { this.activelyFocusNode(this.focusedNode, null); // Even though focus was restored, check if it's lost again. It's @@ -535,11 +470,6 @@ export class FocusManager { this.focusNode(capturedNode); } }, 0); - } else { - // If the ephemeral element lost focus then do not force it back since - // that likely will override the user's own attempt to move focus away - // from the ephemeral experience. - this.defocusCurrentFocusedNode(); } }; } @@ -548,7 +478,7 @@ export class FocusManager { * @returns whether something is currently holding ephemeral focus */ ephemeralFocusTaken(): boolean { - return !!this.ephemerallyFocusedElement; + return this.currentlyHoldsEphemeralFocus; } /** @@ -586,7 +516,7 @@ export class FocusManager { // The current node will likely be defocused while ephemeral focus is held, // but internal manager state shouldn't change since the node should be // restored upon exiting ephemeral focus mode. - if (this.focusedNode && !this.ephemerallyFocusedElement) { + if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) { this.passivelyFocusNode(this.focusedNode, null); this.updateFocusedNode(null); } diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index fac8368a952..fc792fbaf24 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -155,7 +155,7 @@ suite('DropDownDiv', function () { }); test('Escape dismisses DropDownDiv', function () { let hidden = false; - Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, false, () => { + Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, () => { hidden = true; }); assert.isFalse(hidden); @@ -252,34 +252,6 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); - - test('without auto close on lost focus lost focus does not hide drop-down div', function () { - const block = this.setUpBlockWithField(); - const field = Array.from(block.getFields())[0]; - Blockly.getFocusManager().focusNode(block); - Blockly.DropDownDiv.showPositionedByField(field, null, null, true, false); - - // Focus an element outside of the drop-down. - document.getElementById('nonTreeElementForEphemeralFocus').focus(); - - // Even though the drop-down lost focus, it should still be visible. - const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); - assert.strictEqual(dropDownDivElem.style.opacity, '1'); - }); - - test('with auto close on lost focus lost focus hides drop-down div', function () { - const block = this.setUpBlockWithField(); - const field = Array.from(block.getFields())[0]; - Blockly.getFocusManager().focusNode(block); - Blockly.DropDownDiv.showPositionedByField(field, null, null, true, true); - - // Focus an element outside of the drop-down. - document.getElementById('nonTreeElementForEphemeralFocus').focus(); - - // the drop-down should now be hidden since it lost focus. - const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); - assert.strictEqual(dropDownDivElem.style.opacity, '0'); - }); }); suite('showPositionedByBlock()', function () { @@ -353,48 +325,6 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); - - test('without auto close on lost focus lost focus does not hide drop-down div', function () { - const block = this.setUpBlockWithField(); - const field = Array.from(block.getFields())[0]; - Blockly.getFocusManager().focusNode(block); - Blockly.DropDownDiv.showPositionedByBlock( - field, - block, - null, - null, - true, - false, - ); - - // Focus an element outside of the drop-down. - document.getElementById('nonTreeElementForEphemeralFocus').focus(); - - // Even though the drop-down lost focus, it should still be visible. - const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); - assert.strictEqual(dropDownDivElem.style.opacity, '1'); - }); - - test('with auto close on lost focus lost focus hides drop-down div', function () { - const block = this.setUpBlockWithField(); - const field = Array.from(block.getFields())[0]; - Blockly.getFocusManager().focusNode(block); - Blockly.DropDownDiv.showPositionedByBlock( - field, - block, - null, - null, - true, - true, - ); - - // Focus an element outside of the drop-down. - document.getElementById('nonTreeElementForEphemeralFocus').focus(); - - // the drop-down should now be hidden since it lost focus. - const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); - assert.strictEqual(dropDownDivElem.style.opacity, '0'); - }); }); suite('hideWithoutAnimation()', function () { diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index cb4a43652fe..26dcb8dbe68 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -5975,172 +5975,5 @@ suite('FocusManager', function () { ); assert.strictEqual(document.activeElement, nodeElem); }); - - test('with focus change callback initially calls focus change callback with initial state', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - - this.focusManager.takeEphemeralFocus(ephemeralElement, callback); - - assert.strictEqual(callback.callCount, 1); - assert.isTrue(callback.firstCall.calledWithExactly(true)); - }); - - test('with focus change callback finishes ephemeral does not calls focus change callback again', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - const finishFocusCallback = this.focusManager.takeEphemeralFocus( - ephemeralElement, - callback, - ); - callback.resetHistory(); - - finishFocusCallback(); - - assert.isFalse(callback.called); - }); - - test('with focus change callback set focus to ephemeral child does not call focus change callback again', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - const ephemeralElementChild = document.getElementById( - 'nonTreeElementForEphemeralFocus.child1', - ); - this.focusManager.takeEphemeralFocus(ephemeralElement, callback); - callback.resetHistory(); - - ephemeralElementChild.focus(); - - // Focusing a child element shouldn't invoke the callback since the - // ephemeral element's tree still holds focus. - assert.isFalse(callback.called); - }); - - test('with focus change callback set focus to non-ephemeral element calls focus change callback', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - const ephemeralElement2 = document.getElementById( - 'nonTreeElementForEphemeralFocus2', - ); - this.focusManager.takeEphemeralFocus(ephemeralElement, callback); - - ephemeralElement2.focus(); - - // There should be a second call that indicates focus was lost. - assert.strictEqual(callback.callCount, 2); - assert.isTrue(callback.secondCall.calledWithExactly(false)); - }); - - test('with focus change callback set focus to non-ephemeral element then back calls focus change callback again', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - const ephemeralElementChild = document.getElementById( - 'nonTreeElementForEphemeralFocus.child1', - ); - const ephemeralElement2 = document.getElementById( - 'nonTreeElementForEphemeralFocus2', - ); - this.focusManager.takeEphemeralFocus(ephemeralElement, callback); - ephemeralElement2.focus(); - - ephemeralElementChild.focus(); - - // The latest call should be returning focus. - assert.strictEqual(callback.callCount, 3); - assert.isTrue(callback.thirdCall.calledWithExactly(true)); - }); - - test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral', function () { - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - this.focusManager.focusNode(this.testFocusableTree2Node1); - const ephemeralElement = document.getElementById( - 'nonTreeGroupForEphemeralFocus', - ); - const ephemeralElement2 = document.getElementById( - 'nonTreeElementForEphemeralFocus2', - ); - const finishFocusCallback = this.focusManager.takeEphemeralFocus( - ephemeralElement, - (hasFocus) => { - if (!hasFocus) finishFocusCallback(); - }, - ); - - // Force focus away, triggering the callback's automatic returning logic. - ephemeralElement2.focus(); - - // The original focused node should be restored. - const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); - const activeElems = Array.from( - document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), - ); - assert.strictEqual( - this.focusManager.getFocusedNode(), - this.testFocusableTree2Node1, - ); - assert.strictEqual(activeElems.length, 1); - assert.includesClass( - nodeElem.classList, - FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, - ); - assert.strictEqual(document.activeElement, nodeElem); - }); - - test('with focus on non-ephemeral element ephemeral ended does not restore to focused node', function () { - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - this.focusManager.focusNode(this.testFocusableTree2Node1); - const ephemeralElement = document.getElementById( - 'nonTreeGroupForEphemeralFocus', - ); - const ephemeralElement2 = document.getElementById( - 'nonTreeElementForEphemeralFocus2', - ); - const finishFocusCallback = - this.focusManager.takeEphemeralFocus(ephemeralElement); - // Force focus away, triggering the callback's automatic returning logic. - ephemeralElement2.focus(); - - finishFocusCallback(); - - // The original node should not be focused since the ephemeral element - // lost its own DOM focus while ephemeral focus was active. Instead, the - // newly active element should still hold focus. - const activeElems = Array.from( - document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), - ); - const passiveElems = Array.from( - document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), - ); - assert.isEmpty(activeElems); - assert.strictEqual(passiveElems.length, 1); - assert.includesClass( - this.testFocusableTree2Node1.getFocusableElement().classList, - FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, - ); - assert.strictEqual(document.activeElement, ephemeralElement2); - assert.isFalse(this.focusManager.ephemeralFocusTaken()); - }); }); }); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index fea0fb18e84..208c2995596 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -94,13 +94,7 @@
Unfocusable element
-
-
-
-
+
From efb5a2e7f156f4eefdd66015b43c94681e1edba1 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Mon, 7 Jul 2025 09:49:38 -0700 Subject: [PATCH 51/67] fix: check for a drag specifically rather than a gesture for shortcuts (#9194) --- core/shortcut_items.ts | 7 +++---- tests/mocha/shortcut_items_test.js | 16 ++++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 062d0cb4e77..f621f93d39f 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -11,7 +11,6 @@ import * as clipboard from './clipboard.js'; import {RenderedWorkspaceComment} from './comments.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; -import {Gesture} from './gesture.js'; import {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {isDraggable} from './interfaces/i_draggable.js'; @@ -67,7 +66,7 @@ export function registerDelete() { focused != null && isIDeletable(focused) && focused.isDeletable() && - !Gesture.inProgress() && + !workspace.isDragging() && // Don't delete the block if a field editor is open !getFocusManager().ephemeralFocusTaken() ); @@ -322,7 +321,7 @@ export function registerUndo() { preconditionFn(workspace) { return ( !workspace.isReadOnly() && - !Gesture.inProgress() && + !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() ); }, @@ -360,7 +359,7 @@ export function registerRedo() { name: names.REDO, preconditionFn(workspace) { return ( - !Gesture.inProgress() && + !workspace.isDragging() && !workspace.isReadOnly() && !getFocusManager().ephemeralFocusTaken() ); diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index 4ab83d8e1e2..eaadef01eb1 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -434,13 +434,13 @@ suite('Keyboard Shortcut Items', function () { }); }); }); - // Do not undo if a gesture is in progress. - suite('Gesture in progress', function () { + // Do not undo if a drag is in progress. + suite('Drag in progress', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; const keyEvent = testCase[1]; test(testCaseName, function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(keyEvent); sinon.assert.notCalled(this.undoSpy); sinon.assert.notCalled(this.hideChaffSpy); @@ -494,13 +494,13 @@ suite('Keyboard Shortcut Items', function () { }); }); }); - // Do not undo if a gesture is in progress. - suite('Gesture in progress', function () { + // Do not redo if a drag is in progress. + suite('Drag in progress', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; const keyEvent = testCase[1]; test(testCaseName, function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(keyEvent); sinon.assert.notCalled(this.redoSpy); sinon.assert.notCalled(this.hideChaffSpy); @@ -534,8 +534,8 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.calledWith(this.undoSpy, true); sinon.assert.calledOnce(this.hideChaffSpy); }); - test('Not called when a gesture is in progress', function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + test('Not called when a drag is in progress', function () { + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(this.ctrlYEvent); sinon.assert.notCalled(this.undoSpy); sinon.assert.notCalled(this.hideChaffSpy); From b741d78b5b3e6394bbd29a84309f99743270d8bf Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Mon, 7 Jul 2025 17:54:00 +0100 Subject: [PATCH 52/67] refactor(CSS): move box-sizing to core/css.ts (#9201) Apply box-sizing to all of Blockly (and thereby obviate the need to apply it to .blocklyHtmlInput in particular. --- core/toolbox/toolbox.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 31bb2b6363d..4979fdfa40f 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -1172,6 +1172,7 @@ Css.register(` /* Category tree in Toolbox. */ .blocklyToolbox { + box-sizing: border-box; user-select: none; -ms-user-select: none; -webkit-user-select: none; From 7184cb24f28c99a7b74e2f0f6721428c54e312ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:23:25 +0100 Subject: [PATCH 53/67] chore(deps): bump eslint-config-prettier from 10.1.1 to 10.1.5 (#9209) Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 10.1.1 to 10.1.5. - [Release notes](https://github.com/prettier/eslint-config-prettier/releases) - [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/eslint-config-prettier/compare/v10.1.1...v10.1.5) --- updated-dependencies: - dependency-name: eslint-config-prettier dependency-version: 10.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28f9b3bd6c2..d1977538e33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4060,14 +4060,16 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz", - "integrity": "sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw==", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, - "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, "peerDependencies": { "eslint": ">=7.0.0" } From b890e32bf98bc00528ad325bdf90272e80d729ab Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Mon, 7 Jul 2025 11:48:55 -0700 Subject: [PATCH 54/67] Re-enable undo/redo tests now that focus is working --- tests/browser/test/delete_blocks_test.mjs | 8 ++++---- tests/browser/test/extensive_test.mjs | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/browser/test/delete_blocks_test.mjs b/tests/browser/test/delete_blocks_test.mjs index 133716fa875..5c2499c41f3 100644 --- a/tests/browser/test/delete_blocks_test.mjs +++ b/tests/browser/test/delete_blocks_test.mjs @@ -8,6 +8,7 @@ import * as chai from 'chai'; import {Key} from 'webdriverio'; import { clickBlock, + clickWorkspace, contextMenuSelect, getAllBlocks, getBlockElementById, @@ -176,8 +177,7 @@ suite('Delete blocks', function (done) { ); }); - // TODO(#9029) enable this test once deleting a block doesn't lose focus - test.skip('Undo block deletion', async function () { + test('Undo block deletion', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. await clickBlock(this.browser, this.firstBlock.id, {button: 1}); @@ -194,8 +194,7 @@ suite('Delete blocks', function (done) { ); }); - // TODO(#9029) enable this test once deleting a block doesn't lose focus - test.skip('Redo block deletion', async function () { + test('Redo block deletion', async function () { const before = (await getAllBlocks(this.browser)).length; // Get first print block, click to select it, and delete it using backspace key. await clickBlock(this.browser, this.firstBlock.id, {button: 1}); @@ -205,6 +204,7 @@ suite('Delete blocks', function (done) { await this.browser.keys([Key.Ctrl, 'z']); await this.browser.pause(PAUSE_TIME); // Redo + await clickWorkspace(this.browser); await this.browser.keys([Key.Ctrl, Key.Shift, 'z']); await this.browser.pause(PAUSE_TIME); const after = (await getAllBlocks(this.browser)).length; diff --git a/tests/browser/test/extensive_test.mjs b/tests/browser/test/extensive_test.mjs index bef8bc9345d..48c066c399d 100644 --- a/tests/browser/test/extensive_test.mjs +++ b/tests/browser/test/extensive_test.mjs @@ -40,8 +40,7 @@ suite('This tests loading Large Configuration and Deletion', function (done) { chai.assert.equal(allBlocks.length, 10); }); - // TODO(#8793) Re-enable test after deleting a block updates focus correctly. - test.skip('undoing delete block results in the correct number of blocks', async function () { + test('undoing delete block results in the correct number of blocks', async function () { await this.browser.keys([Key.Ctrl, 'z']); await this.browser.pause(PAUSE_TIME); const allBlocks = await getAllBlocks(this.browser); From 97d0e45418cc5da58859bd8e911ebdcdcc8c68ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 13:03:55 -0700 Subject: [PATCH 55/67] chore(deps): bump eslint-plugin-prettier from 5.5.0 to 5.5.1 (#9206) Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 5.5.0 to 5.5.1. - [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases) - [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.5.0...v5.5.1) --- updated-dependencies: - dependency-name: eslint-plugin-prettier dependency-version: 5.5.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d1977538e33..2f7e625dad4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4122,11 +4122,10 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.0.tgz", - "integrity": "sha512-8qsOYwkkGrahrgoUv76NZi23koqXOGiiEzXMrT8Q7VcYaUISR+5MorIUxfWqYXN0fN/31WbSrxCxFkVQ43wwrA==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz", + "integrity": "sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==", "dev": true, - "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.11.7" From dfd565957b7652929ac07f4f7330390dfc459e94 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 7 Jul 2025 13:55:40 -0700 Subject: [PATCH 56/67] refactor: Ensure that the workspace cursor is never null. (#9210) --- core/marker_manager.ts | 15 +++++++-------- core/workspace_svg.ts | 17 ++++------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/core/marker_manager.ts b/core/marker_manager.ts index 95a2d9b8bce..e94aa3e966a 100644 --- a/core/marker_manager.ts +++ b/core/marker_manager.ts @@ -11,7 +11,7 @@ */ // Former goog.module ID: Blockly.MarkerManager -import type {LineCursor} from './keyboard_nav/line_cursor.js'; +import {LineCursor} from './keyboard_nav/line_cursor.js'; import type {Marker} from './keyboard_nav/marker.js'; import type {WorkspaceSvg} from './workspace_svg.js'; @@ -23,7 +23,7 @@ export class MarkerManager { static readonly LOCAL_MARKER = 'local_marker_1'; /** The cursor. */ - private cursor: LineCursor | null = null; + private cursor: LineCursor; /** The map of markers for the workspace. */ private markers = new Map(); @@ -32,7 +32,9 @@ export class MarkerManager { * @param workspace The workspace for the marker manager. * @internal */ - constructor(private readonly workspace: WorkspaceSvg) {} + constructor(private readonly workspace: WorkspaceSvg) { + this.cursor = new LineCursor(this.workspace); + } /** * Register the marker by adding it to the map of markers. @@ -72,7 +74,7 @@ export class MarkerManager { * * @returns The cursor for this workspace. */ - getCursor(): LineCursor | null { + getCursor(): LineCursor { return this.cursor; } @@ -109,9 +111,6 @@ export class MarkerManager { this.unregisterMarker(markerId); } this.markers.clear(); - if (this.cursor) { - this.cursor.dispose(); - this.cursor = null; - } + this.cursor.dispose(); } } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 00eef565394..d713f11cf43 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -480,10 +480,7 @@ export class WorkspaceSvg * @internal */ getMarker(id: string): Marker | null { - if (this.markerManager) { - return this.markerManager.getMarker(id); - } - return null; + return this.markerManager.getMarker(id); } /** @@ -491,11 +488,8 @@ export class WorkspaceSvg * * @returns The cursor for the workspace. */ - getCursor(): LineCursor | null { - if (this.markerManager) { - return this.markerManager.getCursor(); - } - return null; + getCursor(): LineCursor { + return this.markerManager.getCursor(); } /** @@ -899,10 +893,7 @@ export class WorkspaceSvg } this.renderer.dispose(); - - if (this.markerManager) { - this.markerManager.dispose(); - } + this.markerManager.dispose(); super.dispose(); From e3d17becbdff79eca83a19e6d1559c64ccfffb9e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 7 Jul 2025 15:28:54 -0700 Subject: [PATCH 57/67] fix: Improve workspace comment keyboard navigation behavior. (#9211) * fix: Prevent tabbing into workspace comments. * fix: Focus workspace comments when navigating to them using the keyboard. --- core/comments/comment_editor.ts | 1 + core/keyboard_nav/line_cursor.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/core/comments/comment_editor.ts b/core/comments/comment_editor.ts index 9a1907e9117..69dadd884f5 100644 --- a/core/comments/comment_editor.ts +++ b/core/comments/comment_editor.ts @@ -52,6 +52,7 @@ export class CommentEditor implements IFocusableNode { dom.HTML_NS, 'textarea', ) as HTMLTextAreaElement; + this.textArea.setAttribute('tabindex', '-1'); dom.addClass(this.textArea, 'blocklyCommentText'); dom.addClass(this.textArea, 'blocklyTextarea'); dom.addClass(this.textArea, 'blocklyText'); diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index aeb80cff170..a301c3b37e0 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -401,6 +401,8 @@ export class LineCursor extends Marker { block.workspace.scrollBoundsIntoView( block.getBoundingRectangleWithoutChildren(), ); + } else if (newNode instanceof RenderedWorkspaceComment) { + newNode.workspace.scrollBoundsIntoView(newNode.getBoundingRectangle()); } } From 0e16b0405a5d028440c4f4de8750d34c3a874144 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 7 Jul 2025 15:52:38 -0700 Subject: [PATCH 58/67] fix: Auto close drop-down divs on lost focus (reapply) (#9213) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes https://github.com/google/blockly-keyboard-experimentation/issues/563 ### Proposed Changes This introduces support in `FocusManager` to receive feedback on when an ephemerally focused element entirely loses focus (that is, neither it nor any of its descendants have focus). This also introduces a behavior change for drop-down divs using the previously mentioned functionality to automatically close themselves when they lose focus for any reason (e.g. clicking outside of the div or tab navigating away from it). Finally, and **importantly**, this adds a case where ephemeral focus does _not_ automatically return to the previously focused node: when focus is lost to the ephemerally focused element's tree and isn't instead put on another focused node. ### Reason for Changes Ultimately, focus is probably the best proxy for cases when a drop-down div ought to no longer be open. However, tracking focus only within the scope of the drop-down div utility is rather difficult since a lot of the same problems that `FocusManager` handles also occur here (with regards to both descendants and outside elements receiving focus). It made more sense to expand `FocusManager`'s ephemeral focus support: - It was easier to implement this `FocusManager` and in a way that's much more robust (since it's leveraging existing event handlers). - Using `FocusManager` trivialized the solution for drop-down divs. - There could be other use cases where custom ephemeral focus uses might benefit from knowing when they lose focus. This new support is enabled by default for all drop-down divs, but can be disabled by callers if they wish to revert to the previous behavior of not auto-closing. The change for whether to restore ephemeral focus was needed to fix a drawback that arises from the automatic returning of ephemeral focus introduced in this PR: when a user clicks out of an open drop-down menu it will restore focus back to the node that held focus prior to taking ephemeral focus (since it properly hides the drop-down div and restores focus). This creates awkward behavior issues for both mouse and keyboard users: - For mouse: trying to open a drop-down outside of Blockly will automatically close the drop-down when the Blockly drop-down finishes closing (since focus is stolen back away from the thing the user clicked on). - For keyboard: tab navigating out of Blockly tries to force focus back to Blockly. **New in v2 of this PR**: Commit 0363d67c183e8488e7bce9f0d8d3b03ca44bad64 is the main one that prevents #9203 from being reintroduced by ensuring that widget div only clears its contents after ephemeral focus has returned. This was missed in the first audit since it wasn't clear that this line, in particular, can cause a div with focus to be removed and thus focus lost: https://github.com/google/blockly/blob/dfd565957b7652929ac07f4f7330390dfc459e94/core/widgetdiv.ts#L156 ### Test Coverage New tests have been added for both the drop-down div and `FocusManager` components, and have been verified as failing without the new behaviors in place. There may be other edge cases worth testing for `FocusManager` in particular, but the tests introduced in this PR seem to cover the most important cases. **New in v2 of this PR**: A test was added to validate that widget div now clears its contents only after ephemeral focus has returned to avoid the desyncing scenario that led to #9203. This test has been verified to fail without the fix. There are also a few new tests being added in the keyboard navigation plugin repository that also validate this behavior at a higher level (see https://github.com/google/blockly-keyboard-experimentation/pull/649). Demonstration of the new behavior: [Screen recording 2025-07-01 6.28.37 PM.webm](https://github.com/user-attachments/assets/7af29fed-1ba1-4828-a6cd-65bb94509e72) ### Documentation No new documentation changes seem needed beyond the code documentation updates. ### Additional Information It's also possible to change the automatic restoration behavior to be conditional instead of always assuming focus shouldn't be reset if focus leaves the ephemeral element, but that's probably a better change if there's an actual user issue discovered with this approach. This was originally introduced in #9175 but it was reverted in #9204 due to #9203. --- core/dropdowndiv.ts | 36 ++++++- core/focus_manager.ts | 90 ++++++++++++++-- core/widgetdiv.ts | 18 ++-- tests/mocha/dropdowndiv_test.js | 72 ++++++++++++- tests/mocha/focus_manager_test.js | 167 ++++++++++++++++++++++++++++++ tests/mocha/index.html | 8 +- tests/mocha/widget_div_test.js | 21 ++++ 7 files changed, 392 insertions(+), 20 deletions(-) diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index ceab467a895..608fe9b5b2c 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -213,6 +213,8 @@ export function setColour(backgroundColour: string, borderColour: string) { * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByBlock( @@ -221,11 +223,13 @@ export function showPositionedByBlock( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, + autoCloseOnLostFocus: boolean = true, ): boolean { return showPositionedByRect( getScaledBboxOfBlock(block), field as Field, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -245,6 +249,8 @@ export function showPositionedByBlock( * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByField( @@ -252,12 +258,14 @@ export function showPositionedByField( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, + autoCloseOnLostFocus: boolean = true, ): boolean { positionToField = true; return showPositionedByRect( getScaledBboxOfField(field as Field), field as Field, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -302,12 +310,15 @@ function getScaledBboxOfField(field: Field): Rect { * according to the drop-down div's lifetime. Note that if a false value is * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( bBox: Rect, field: Field, manageEphemeralFocus: boolean, + autoCloseOnLostFocus: boolean, opt_onHide?: () => void, opt_secondaryYOffset?: number, ): boolean { @@ -335,6 +346,7 @@ function showPositionedByRect( secondaryX, secondaryY, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, ); } @@ -357,6 +369,8 @@ function showPositionedByRect( * @param opt_onHide Optional callback for when the drop-down is hidden. * @param manageEphemeralFocus Whether ephemeral focus should be managed * according to the widget div's lifetime. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered at the primary origin point. * @internal */ @@ -368,6 +382,7 @@ export function show( secondaryX: number, secondaryY: number, manageEphemeralFocus: boolean, + autoCloseOnLostFocus: boolean, opt_onHide?: () => void, ): boolean { owner = newOwner as Field; @@ -394,7 +409,18 @@ export function show( // Ephemeral focus must happen after the div is fully visible in order to // ensure that it properly receives focus. if (manageEphemeralFocus) { - returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + const autoCloseCallback = autoCloseOnLostFocus + ? (hasFocus: boolean) => { + // If focus is ever lost, close the drop-down. + if (!hasFocus) { + hide(); + } + } + : null; + returnEphemeralFocus = getFocusManager().takeEphemeralFocus( + div, + autoCloseCallback, + ); } return atOrigin; @@ -693,7 +719,6 @@ export function hideWithoutAnimation() { onHide(); onHide = null; } - clearContent(); owner = null; (common.getMainWorkspace() as WorkspaceSvg).markFocused(); @@ -702,6 +727,13 @@ export function hideWithoutAnimation() { returnEphemeralFocus(); returnEphemeralFocus = null; } + + // Content must be cleared after returning ephemeral focus since otherwise it + // may force focus changes which could desynchronize the focus manager and + // make it think the user directed focus away from the drop-down div (which + // will then notify it to not restore focus back to any previously focused + // node). + clearContent(); } /** diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 02e0591070f..31453b827b5 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -17,6 +17,14 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; */ export type ReturnEphemeralFocus = () => void; +/** + * Type declaration for an optional callback to observe when an element with + * ephemeral focus has its DOM focus changed before ephemeral focus is returned. + * + * See FocusManager.takeEphemeralFocus for more details. + */ +export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void; + /** * Represents an IFocusableTree that has been registered for focus management in * FocusManager. @@ -78,7 +86,10 @@ export class FocusManager { private previouslyFocusedNode: IFocusableNode | null = null; private registeredTrees: Array = []; - private currentlyHoldsEphemeralFocus: boolean = false; + private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null; + private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null = + null; + private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false; private lockFocusStateChanges: boolean = false; private recentlyLostAllFocus: boolean = false; private isUpdatingFocusedNode: boolean = false; @@ -118,6 +129,21 @@ export class FocusManager { } else { this.defocusCurrentFocusedNode(); } + + const ephemeralFocusElem = this.ephemerallyFocusedElement; + if (ephemeralFocusElem) { + const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus; + const hasFocus = + !!element && + element instanceof Node && + ephemeralFocusElem.contains(element); + if (hadFocus !== hasFocus) { + if (this.ephemeralDomFocusChangedCallback) { + this.ephemeralDomFocusChangedCallback(hasFocus); + } + this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus; + } + } }; // Register root document focus listeners for tracking when focus leaves all @@ -313,7 +339,7 @@ export class FocusManager { */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus; + const mustRestoreUpdatingNode = !this.ephemerallyFocusedElement; if (mustRestoreUpdatingNode) { // Disable state syncing from DOM events since possible calls to focus() // below will loop a call back to focusNode(). @@ -395,7 +421,7 @@ export class FocusManager { this.removeHighlight(nextTreeRoot); } - if (!this.currentlyHoldsEphemeralFocus) { + if (!this.ephemerallyFocusedElement) { // Only change the actively focused node if ephemeral state isn't held. this.activelyFocusNode(nodeToFocus, prevTree ?? null); } @@ -423,24 +449,50 @@ export class FocusManager { * the returned lambda is called. Additionally, only 1 ephemeral focus context * can be active at any given time (attempting to activate more than one * simultaneously will result in an error being thrown). + * + * Important details regarding the onFocusChangedInDom callback: + * - This method will be called initially with a value of 'true' indicating + * that the ephemeral element has been focused, so callers can rely on that, + * if needed, for initialization logic. + * - It's safe to end ephemeral focus in this callback (and is encouraged for + * callers that wish to automatically end ephemeral focus when the user + * directs focus outside of the element). + * - The element AND all of its descendants are tracked for focus. That means + * the callback will ONLY be called with a value of 'false' if focus + * completely leaves the DOM tree for the provided focusable element. + * - It's invalid to return focus on the very first call to the callback, + * however this is expected to be impossible, anyway, since this method + * won't return until after the first call to the callback (thus there will + * be no means to return ephemeral focus). + * + * @param focusableElement The element that should be focused until returned. + * @param onFocusChangedInDom An optional callback which will be notified + * whenever the provided element's focus changes before ephemeral focus is + * returned. See the details above for specifics. + * @returns A ReturnEphemeralFocus that must be called when ephemeral focus + * should end. */ takeEphemeralFocus( focusableElement: HTMLElement | SVGElement, + onFocusChangedInDom: EphemeralFocusChangedInDom | null = null, ): ReturnEphemeralFocus { this.ensureManagerIsUnlocked(); - if (this.currentlyHoldsEphemeralFocus) { + if (this.ephemerallyFocusedElement) { throw Error( `Attempted to take ephemeral focus when it's already held, ` + `with new element: ${focusableElement}.`, ); } - this.currentlyHoldsEphemeralFocus = true; + this.ephemerallyFocusedElement = focusableElement; + this.ephemeralDomFocusChangedCallback = onFocusChangedInDom; if (this.focusedNode) { this.passivelyFocusNode(this.focusedNode, null); } focusableElement.focus(); + this.ephemerallyFocusedElementCurrentlyHasFocus = true; + const focusedNodeAtStart = this.focusedNode; let hasFinishedEphemeralFocus = false; return () => { if (hasFinishedEphemeralFocus) { @@ -450,9 +502,22 @@ export class FocusManager { ); } hasFinishedEphemeralFocus = true; - this.currentlyHoldsEphemeralFocus = false; - - if (this.focusedNode) { + this.ephemerallyFocusedElement = null; + this.ephemeralDomFocusChangedCallback = null; + + const hadEphemeralFocusAtEnd = + this.ephemerallyFocusedElementCurrentlyHasFocus; + this.ephemerallyFocusedElementCurrentlyHasFocus = false; + + // If the user forced away DOM focus during ephemeral focus, then + // determine whether focus should be restored back to a focusable node + // after ephemeral focus ends. Generally it shouldn't be, but in some + // cases (such as the user focusing an actual focusable node) it then + // should be. + const hasNewFocusedNode = focusedNodeAtStart !== this.focusedNode; + const shouldRestoreToNode = hasNewFocusedNode || hadEphemeralFocusAtEnd; + + if (this.focusedNode && shouldRestoreToNode) { this.activelyFocusNode(this.focusedNode, null); // Even though focus was restored, check if it's lost again. It's @@ -470,6 +535,11 @@ export class FocusManager { this.focusNode(capturedNode); } }, 0); + } else { + // If the ephemeral element lost focus then do not force it back since + // that likely will override the user's own attempt to move focus away + // from the ephemeral experience. + this.defocusCurrentFocusedNode(); } }; } @@ -478,7 +548,7 @@ export class FocusManager { * @returns whether something is currently holding ephemeral focus */ ephemeralFocusTaken(): boolean { - return this.currentlyHoldsEphemeralFocus; + return !!this.ephemerallyFocusedElement; } /** @@ -516,7 +586,7 @@ export class FocusManager { // The current node will likely be defocused while ephemeral focus is held, // but internal manager state shouldn't change since the node should be // restored upon exiting ephemeral focus mode. - if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) { + if (this.focusedNode && !this.ephemerallyFocusedElement) { this.passivelyFocusNode(this.focusedNode, null); this.updateFocusedNode(null); } diff --git a/core/widgetdiv.ts b/core/widgetdiv.ts index d07f7fb502b..f9b89de56d2 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -146,6 +146,18 @@ export function hide() { const div = containerDiv; if (!div) return; + + (common.getMainWorkspace() as WorkspaceSvg).markFocused(); + + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } + + // Content must be cleared after returning ephemeral focus since otherwise it + // may force focus changes which could desynchronize the focus manager and + // make it think the user directed focus away from the widget div (which will + // then notify it to not restore focus back to any previously focused node). div.style.display = 'none'; div.style.left = ''; div.style.top = ''; @@ -163,12 +175,6 @@ export function hide() { dom.removeClass(div, themeClassName); themeClassName = ''; } - (common.getMainWorkspace() as WorkspaceSvg).markFocused(); - - if (returnEphemeralFocus) { - returnEphemeralFocus(); - returnEphemeralFocus = null; - } } /** diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index fc792fbaf24..fac8368a952 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -155,7 +155,7 @@ suite('DropDownDiv', function () { }); test('Escape dismisses DropDownDiv', function () { let hidden = false; - Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, () => { + Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, false, () => { hidden = true; }); assert.isFalse(hidden); @@ -252,6 +252,34 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); + + test('without auto close on lost focus lost focus does not hide drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, true, false); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // Even though the drop-down lost focus, it should still be visible. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + }); + + test('with auto close on lost focus lost focus hides drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, true, true); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // the drop-down should now be hidden since it lost focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); }); suite('showPositionedByBlock()', function () { @@ -325,6 +353,48 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); + + test('without auto close on lost focus lost focus does not hide drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + true, + false, + ); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // Even though the drop-down lost focus, it should still be visible. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + }); + + test('with auto close on lost focus lost focus hides drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + true, + true, + ); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // the drop-down should now be hidden since it lost focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); }); suite('hideWithoutAnimation()', function () { diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index 26dcb8dbe68..cb4a43652fe 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -5975,5 +5975,172 @@ suite('FocusManager', function () { ); assert.strictEqual(document.activeElement, nodeElem); }); + + test('with focus change callback initially calls focus change callback with initial state', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + + assert.strictEqual(callback.callCount, 1); + assert.isTrue(callback.firstCall.calledWithExactly(true)); + }); + + test('with focus change callback finishes ephemeral does not calls focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const finishFocusCallback = this.focusManager.takeEphemeralFocus( + ephemeralElement, + callback, + ); + callback.resetHistory(); + + finishFocusCallback(); + + assert.isFalse(callback.called); + }); + + test('with focus change callback set focus to ephemeral child does not call focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElementChild = document.getElementById( + 'nonTreeElementForEphemeralFocus.child1', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + callback.resetHistory(); + + ephemeralElementChild.focus(); + + // Focusing a child element shouldn't invoke the callback since the + // ephemeral element's tree still holds focus. + assert.isFalse(callback.called); + }); + + test('with focus change callback set focus to non-ephemeral element calls focus change callback', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + + ephemeralElement2.focus(); + + // There should be a second call that indicates focus was lost. + assert.strictEqual(callback.callCount, 2); + assert.isTrue(callback.secondCall.calledWithExactly(false)); + }); + + test('with focus change callback set focus to non-ephemeral element then back calls focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElementChild = document.getElementById( + 'nonTreeElementForEphemeralFocus.child1', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + ephemeralElement2.focus(); + + ephemeralElementChild.focus(); + + // The latest call should be returning focus. + assert.strictEqual(callback.callCount, 3); + assert.isTrue(callback.thirdCall.calledWithExactly(true)); + }); + + test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + const finishFocusCallback = this.focusManager.takeEphemeralFocus( + ephemeralElement, + (hasFocus) => { + if (!hasFocus) finishFocusCallback(); + }, + ); + + // Force focus away, triggering the callback's automatic returning logic. + ephemeralElement2.focus(); + + // The original focused node should be restored. + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(activeElems.length, 1); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, nodeElem); + }); + + test('with focus on non-ephemeral element ephemeral ended does not restore to focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + // Force focus away, triggering the callback's automatic returning logic. + ephemeralElement2.focus(); + + finishFocusCallback(); + + // The original node should not be focused since the ephemeral element + // lost its own DOM focus while ephemeral focus was active. Instead, the + // newly active element should still hold focus. + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + const passiveElems = Array.from( + document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + assert.strictEqual(passiveElems.length, 1); + assert.includesClass( + this.testFocusableTree2Node1.getFocusableElement().classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, ephemeralElement2); + assert.isFalse(this.focusManager.ephemeralFocusTaken()); + }); }); }); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 208c2995596..fea0fb18e84 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -94,7 +94,13 @@
Unfocusable element
-
+
+
+
+
diff --git a/tests/mocha/widget_div_test.js b/tests/mocha/widget_div_test.js index 61c94247110..b20533bc309 100644 --- a/tests/mocha/widget_div_test.js +++ b/tests/mocha/widget_div_test.js @@ -423,5 +423,26 @@ suite('WidgetDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, blockFocusableElem); }); + + test('for showing nested div with ephemeral focus restores DOM focus', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + const nestedDiv = document.createElement('div'); + nestedDiv.tabIndex = -1; + Blockly.WidgetDiv.getDiv().appendChild(nestedDiv); + Blockly.WidgetDiv.show(field, false, () => {}, null, true); + nestedDiv.focus(); // It's valid to focus this during ephemeral focus. + + // Hiding will cause the now focused child div to be removed, leading to + // ephemeral focus being lost if the implementation doesn't handle + // returning ephemeral focus correctly. + Blockly.WidgetDiv.hide(); + + // Hiding the div should restore focus back to the block. + const blockFocusableElem = block.getFocusableElement(); + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); + assert.strictEqual(document.activeElement, blockFocusableElem); + }); }); }); From dfcdcc19353c222635aab2d88355c60170c11f67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:53:10 -0700 Subject: [PATCH 59/67] chore(deps): bump @microsoft/api-extractor from 7.48.1 to 7.52.8 (#9208) Bumps [@microsoft/api-extractor](https://github.com/microsoft/rushstack/tree/HEAD/apps/api-extractor) from 7.48.1 to 7.52.8. - [Changelog](https://github.com/microsoft/rushstack/blob/main/apps/api-extractor/CHANGELOG.md) - [Commits](https://github.com/microsoft/rushstack/commits/@microsoft/api-extractor_v7.52.8/apps/api-extractor) --- updated-dependencies: - dependency-name: "@microsoft/api-extractor" dependency-version: 7.52.8 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 165 ++++------------------------------------------ 1 file changed, 11 insertions(+), 154 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2f7e625dad4..0dc0d607685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -953,24 +953,24 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.48.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.48.1.tgz", - "integrity": "sha512-HN9Osa1WxqLM66RaqB5nPAadx+nTIQmY/XtkFdaJvusjG8Tus++QqZtD7KPZDSkhEMGHsYeSyeU8qUzCDUXPjg==", + "version": "7.52.8", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.52.8.tgz", + "integrity": "sha512-cszYIcjiNscDoMB1CIKZ3My61+JOhpERGlGr54i6bocvGLrcL/wo9o+RNXMBrb7XgLtKaizZWUpqRduQuHQLdg==", "dev": true, "dependencies": { - "@microsoft/api-extractor-model": "7.30.1", + "@microsoft/api-extractor-model": "7.30.6", "@microsoft/tsdoc": "~0.15.1", "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.10.1", + "@rushstack/node-core-library": "5.13.1", "@rushstack/rig-package": "0.5.3", - "@rushstack/terminal": "0.14.4", - "@rushstack/ts-command-line": "4.23.2", + "@rushstack/terminal": "0.15.3", + "@rushstack/ts-command-line": "5.0.1", "lodash": "~4.17.15", "minimatch": "~3.0.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", - "typescript": "5.4.2" + "typescript": "5.8.2" }, "bin": { "api-extractor": "bin/api-extractor" @@ -988,102 +988,6 @@ "@rushstack/node-core-library": "5.13.1" } }, - "node_modules/@microsoft/api-extractor/node_modules/@microsoft/api-extractor-model": { - "version": "7.30.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.1.tgz", - "integrity": "sha512-CTS2PlASJHxVY8hqHORVb1HdECWOEMcMnM6/kDkPr0RZapAFSIHhg9D4jxuE8g+OWYHtPc10LCpmde5pylTRlA==", - "dev": true, - "dependencies": { - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.10.1" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/@rushstack/node-core-library": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.10.1.tgz", - "integrity": "sha512-BSb/KcyBHmUQwINrgtzo6jiH0HlGFmrUy33vO6unmceuVKTEyL2q+P0fQq2oB5hvXVWOEUhxB2QvlkZluvUEmg==", - "dev": true, - "dependencies": { - "ajv": "~8.13.0", - "ajv-draft-04": "~1.0.0", - "ajv-formats": "~3.0.1", - "fs-extra": "~7.0.1", - "import-lazy": "~4.0.0", - "jju": "~1.4.0", - "resolve": "~1.22.1", - "semver": "~7.5.4" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@microsoft/api-extractor/node_modules/@rushstack/terminal": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.14.4.tgz", - "integrity": "sha512-NxACqERW0PHq8Rpq1V6v5iTHEwkRGxenjEW+VWqRYQ8T9puUzgmGHmEZUaUEDHAe9Qyvp0/Ew04sAiQw9XjhJg==", - "dev": true, - "dependencies": { - "@rushstack/node-core-library": "5.10.1", - "supports-color": "~8.1.1" - }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@microsoft/api-extractor/node_modules/@rushstack/ts-command-line": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.2.tgz", - "integrity": "sha512-JJ7XZX5K3ThBBva38aomgsPv1L7FV6XmSOcR6HtM7HDFZJkepqT65imw26h9ggGqMjsY0R9jcl30tzKcVj9aOQ==", - "dev": true, - "dependencies": { - "@rushstack/terminal": "0.14.4", - "@types/argparse": "1.0.38", - "argparse": "~1.0.9", - "string-argv": "~0.3.1" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/ajv": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", - "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@microsoft/api-extractor/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, "node_modules/@microsoft/api-extractor/node_modules/minimatch": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", @@ -1105,25 +1009,10 @@ "node": ">=0.10.0" } }, - "node_modules/@microsoft/api-extractor/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/@microsoft/api-extractor/node_modules/typescript": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", - "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -4709,38 +4598,6 @@ "node": ">=12.20.0" } }, - "node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs-extra/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/fs-extra/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/fs-mkdirp-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-2.0.1.tgz", From 8580d763b34b10c961d43ae8a61ce76c8669548c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:28:57 +0100 Subject: [PATCH 60/67] chore(deps): bump google-closure-compiler from 20240317.0.0 to 20250625.0.0 (#9187) * chore(deps): bump google-closure-compiler Bumps [google-closure-compiler](https://github.com/google/closure-compiler-npm) from 20240317.0.0 to 20250625.0.0. - [Release notes](https://github.com/google/closure-compiler-npm/releases) - [Commits](https://github.com/google/closure-compiler-npm/compare/v20240317.0.0...v20250625.0.0) --- updated-dependencies: - dependency-name: google-closure-compiler dependency-version: 20250625.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * chore(build): Update import of google-closure-compiler The packaging of this module changed and so how we import it needs to change as well. * fix(build): Remove no-longer-supported compiler option --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Christopher Allen --- package-lock.json | 91 ++++++++++++++++++++++--------- package.json | 2 +- scripts/gulpfiles/build_tasks.mjs | 7 +-- 3 files changed, 67 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0dc0d607685..6645b4edcce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20240317.0.0", + "google-closure-compiler": "^20250625.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", @@ -4949,39 +4949,40 @@ } }, "node_modules/google-closure-compiler": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20240317.0.0.tgz", - "integrity": "sha512-PlC5aU2vwsypKbxyFNXOW4psDZfhDoOr2dCwuo8VcgQji+HVIgRi2lviO66x2SfTi0ilm3kI6rq/RSdOMFczcQ==", + "version": "20250625.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler/-/google-closure-compiler-20250625.0.0.tgz", + "integrity": "sha512-FQ6yKCRYwo4493Rq6lZrxpmWuJGZuuSruCdtArptkoThadzw4TM0YvQJvwRYnQDUpjj6/x7G14l2n/+8G39AIA==", "dev": true, "dependencies": { - "chalk": "4.x", - "google-closure-compiler-java": "^20240317.0.0", + "chalk": "5.x", + "google-closure-compiler-java": "^20250625.0.0", "minimist": "1.x", - "vinyl": "2.x", + "vinyl": "3.x", "vinyl-sourcemaps-apply": "^0.2.0" }, "bin": { "google-closure-compiler": "cli.js" }, "engines": { - "node": ">=10" + "node": ">=18" }, "optionalDependencies": { - "google-closure-compiler-linux": "^20240317.0.0", - "google-closure-compiler-osx": "^20240317.0.0", - "google-closure-compiler-windows": "^20240317.0.0" + "google-closure-compiler-linux": "^20250625.0.0", + "google-closure-compiler-linux-arm64": "^20250625.0.0", + "google-closure-compiler-macos": "^20250625.0.0", + "google-closure-compiler-windows": "^20250625.0.0" } }, "node_modules/google-closure-compiler-java": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20240317.0.0.tgz", - "integrity": "sha512-oWURPChjcCrVfiQOuVtpSoUJVvtOYo41JGEQ2qtArsTGmk/DpWh40vS6hitwKRM/0YzJX/jYUuyt9ibuXXJKmg==", + "version": "20250625.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-java/-/google-closure-compiler-java-20250625.0.0.tgz", + "integrity": "sha512-T916Kvb7JYaIiH9spiJXVKeualLV7PO/KXOJzMhLrW4M6etfvr3s2cTqlhUk+BrxvgxqWBWFbMDRUZbVGPnBaw==", "dev": true }, "node_modules/google-closure-compiler-linux": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20240317.0.0.tgz", - "integrity": "sha512-dYLtcbbJdbbBS0lTy9SzySdVv/aGkpyTekQiW4ADhT/i1p1b4r0wQTKj6kpVVmFvbZ6t9tW/jbXc9EXXNUahZw==", + "version": "20250625.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux/-/google-closure-compiler-linux-20250625.0.0.tgz", + "integrity": "sha512-2cOYLfG7RF49FnGG+yBGlEndE0es8D7+YIGgF8KnGIkxrfiZhOTyQftFx4z48TZ1Be/1JtM2eNXbD2fuR9nJdA==", "cpu": [ "x32", "x64" @@ -4992,13 +4993,24 @@ "linux" ] }, - "node_modules/google-closure-compiler-osx": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-osx/-/google-closure-compiler-osx-20240317.0.0.tgz", - "integrity": "sha512-0mABwjD4HP11rikFd8JRIb9OgPqn9h3o3wS0otufMfmbwS7zRpnnoJkunifhORl3VoR1gFm6vcTC9YziTEFdOw==", + "node_modules/google-closure-compiler-linux-arm64": { + "version": "20250625.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-linux-arm64/-/google-closure-compiler-linux-arm64-20250625.0.0.tgz", + "integrity": "sha512-2vKY8UpL03CFe+k1qFma/HnUZnTM3V3K5ukxmk/Xwt3D7CTwn/039zA3AjxsGW5vLp4guVyLtqbS711KeGpLNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/google-closure-compiler-macos": { + "version": "20250625.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-macos/-/google-closure-compiler-macos-20250625.0.0.tgz", + "integrity": "sha512-/S3d5/oKKw2pEu42Bn+fnoKR0cAjlhOQP1IM0D1aDqNS+jMUXo4bV7RSVB+NSVL65XxIVQOqbnkD5Cfoe8lbrw==", "cpu": [ - "x32", - "x64", "arm64" ], "dev": true, @@ -5008,9 +5020,9 @@ ] }, "node_modules/google-closure-compiler-windows": { - "version": "20240317.0.0", - "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20240317.0.0.tgz", - "integrity": "sha512-fTueVFzNOWURFlXZmrFkAB7yA+jzpA2TeDOYeBEFwVlVGHwi8PV3Q9vCIWlbkE8wLpukKEg5wfRHYrLwVPINCA==", + "version": "20250625.0.0", + "resolved": "https://registry.npmjs.org/google-closure-compiler-windows/-/google-closure-compiler-windows-20250625.0.0.tgz", + "integrity": "sha512-YBNRFTSuWXDJad1pJ1SPjPFpgImrQr7XeW1D9YrPCv1T5cfM8vy01jFkZIDuUha38kHsPvk7kG3rkYYrJpD8+Q==", "cpu": [ "x32", "x64" @@ -5021,6 +5033,33 @@ "win32" ] }, + "node_modules/google-closure-compiler/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/google-closure-compiler/node_modules/vinyl": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", + "dev": true, + "dependencies": { + "clone": "^2.1.2", + "remove-trailing-separator": "^1.1.0", + "replace-ext": "^2.0.0", + "teex": "^1.0.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", diff --git a/package.json b/package.json index 030eed6fd91..af72a233372 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", - "google-closure-compiler": "^20240317.0.0", + "google-closure-compiler": "^20250625.0.0", "gulp": "^5.0.0", "gulp-concat": "^2.6.1", "gulp-gzip": "^1.4.2", diff --git a/scripts/gulpfiles/build_tasks.mjs b/scripts/gulpfiles/build_tasks.mjs index 669e732588d..4b82402fb52 100644 --- a/scripts/gulpfiles/build_tasks.mjs +++ b/scripts/gulpfiles/build_tasks.mjs @@ -19,9 +19,7 @@ import * as fsPromises from 'fs/promises'; import {exec, execSync} from 'child_process'; import {globSync} from 'glob'; -// For v20250609.0.0 and later: -// import {gulp as closureCompiler} from 'google-closure-compiler'; -import ClosureCompiler from 'google-closure-compiler'; +import {gulp as closureCompiler} from 'google-closure-compiler'; import yargs from 'yargs'; import {hideBin} from 'yargs/helpers'; import {rimraf} from 'rimraf'; @@ -31,8 +29,6 @@ import {getPackageJson} from './helper_tasks.mjs'; import {posixPath, quote} from '../helpers.js'; -const closureCompiler = ClosureCompiler.gulp(); - const argv = yargs(hideBin(process.argv)).parse(); //////////////////////////////////////////////////////////// @@ -247,7 +243,6 @@ const JSCOMP_ERROR = [ 'underscore', 'unknownDefines', // 'unusedLocalVariables', // Disabled; see note in JSCOMP_OFF. - 'unusedPrivateMembers', 'uselessCode', 'untranspilableFeatures', // 'visibility', // Disabled; see note in JSCOMP_OFF. From fc9164de8f7355f6a83b9364293f199a7df49ce8 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 8 Jul 2025 13:50:26 -0700 Subject: [PATCH 61/67] fix: Prevent loss of focus when deleting a workspace comment. (#9200) * fix: Prevent loss of focus when deleting a workspace comment. * chore: Add test verifying workspace comment focus behavior on deletion. --- core/comments/delete_comment_bar_button.ts | 2 ++ tests/mocha/workspace_comment_test.js | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts index ccdd0253916..0b7dcd0ea27 100644 --- a/core/comments/delete_comment_bar_button.ts +++ b/core/comments/delete_comment_bar_button.ts @@ -5,6 +5,7 @@ */ import * as browserEvents from '../browser_events.js'; +import {getFocusManager} from '../focus_manager.js'; import * as touch from '../touch.js'; import * as dom from '../utils/dom.js'; import {Svg} from '../utils/svg.js'; @@ -98,5 +99,6 @@ export class DeleteCommentBarButton extends CommentBarButton { this.getParentComment().dispose(); e?.stopPropagation(); + getFocusManager().focusNode(this.workspace); } } diff --git a/tests/mocha/workspace_comment_test.js b/tests/mocha/workspace_comment_test.js index 6e3fa9607a0..3ce276e8579 100644 --- a/tests/mocha/workspace_comment_test.js +++ b/tests/mocha/workspace_comment_test.js @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {assert} from '../../node_modules/chai/chai.js'; import { assertEventFired, createChangeListenerSpy, @@ -167,5 +168,15 @@ suite('Workspace comment', function () { this.workspace.id, ); }); + + test('focuses the workspace when deleted', function () { + const comment = new Blockly.comments.RenderedWorkspaceComment( + this.workspace, + ); + Blockly.getFocusManager().focusNode(comment); + assert.equal(Blockly.getFocusManager().getFocusedNode(), comment); + comment.view.getCommentBarButtons()[1].performAction(); + assert.equal(Blockly.getFocusManager().getFocusedNode(), this.workspace); + }); }); }); From 274891d34e9b4bea8afa08f7d89231fce0dc572f Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Tue, 8 Jul 2025 14:27:50 -0700 Subject: [PATCH 62/67] Responses to comments - Switch to using scrollBoundsIntoView instead of scrolling the flyout - Use webdriverio Key.Escape instead of the string code for it --- tests/browser/test/test_setup.mjs | 206 ++++++----------------- tests/browser/test/toolbox_drag_test.mjs | 7 +- 2 files changed, 58 insertions(+), 155 deletions(-) diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index edbafae4215..d91a109241d 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -172,43 +172,52 @@ export async function getBlockElementById(browser, id) { * @return A Promise that resolves when the actions are completed. */ export async function clickBlock(browser, blockId, clickOptions) { - const findableId = 'clickTargetElement'; // In the browser context, find the element that we want and give it a findable ID. - await browser.execute( - (blockId, newElemId) => { - const block = Blockly.getMainWorkspace().getBlockById(blockId); - // Ensure the block we want to click is within the viewport. - Blockly.getMainWorkspace().scrollBoundsIntoView( - block.getBoundingRectangleWithoutChildren(), - 10, - ); + const elem = await getTargetableBlockElement(browser, blockId, false); + await elem.click(clickOptions); +} + +/** + * Find an element on the block that is suitable for a click or drag. + * + * We can't always use the block's SVG root because clicking will always happen + * in the middle of the block's bounds (including children) by default, which + * causes problems if it has holes (e.g. statement inputs). Instead, this tries + * to get the first text field on the block. It falls back on the block's SVG root. + * @param browser The active WebdriverIO Browser object. + * @param blockId The id of the block to click, as an interactable element. + * @param toolbox True if this block is in the toolbox (which must be open already). + * @return A Promise that returns an appropriate element. + */ +async function getTargetableBlockElement(browser, blockId, toolbox) { + const id = await browser.execute( + (blockId, toolbox, newElemId) => { + const ws = toolbox + ? Blockly.getMainWorkspace().getFlyout().getWorkspace() + : Blockly.getMainWorkspace(); + const block = ws.getBlockById(blockId); + // Ensure the block we want to click/drag is within the viewport. + ws.scrollBoundsIntoView(block.getBoundingRectangleWithoutChildren(), 10); if (!block.isCollapsed()) { for (const input of block.inputList) { for (const field of input.fieldRow) { if (field instanceof Blockly.FieldLabel) { - field.getSvgRoot().id = newElemId; - return; + // Expose the id of the element we want to target + field.getSvgRoot().setAttribute('data-id', field.id_); + return field.getSvgRoot().id; } } } } - // No label field found. Fall back to the block's SVG root. - block.getSvgRoot().id = newElemId; + // No label field found. Fall back to the block's SVG root, which should + // already use the block id. + return block.id; }, blockId, - findableId, + toolbox, ); - // In the test context, get the Webdriverio Element that we've identified. - const elem = await browser.$(`#${findableId}`); - - await elem.click(clickOptions); - - // In the browser context, remove the ID. - await browser.execute((elemId) => { - const clickElem = document.getElementById(elemId); - clickElem.removeAttribute('id'); - }, findableId); + return await getBlockElementById(browser, id); } /** @@ -255,27 +264,14 @@ export async function getCategory(browser, categoryName) { } /** - * @param browser The active WebdriverIO Browser object. - * @param categoryName The name of the toolbox category to search. - * @param n Which block to select, 0-indexed from the top of the category. - * @return A Promise that resolves to the root element of the nth - * block in the given category. - */ -export async function getNthBlockOfCategory(browser, categoryName, n) { - const category = await getCategory(browser, categoryName); - await category.click(); - const block = ( - await browser.$$(`.blocklyFlyout .blocklyBlockCanvas > .blocklyDraggable`) - )[n]; - return block; -} - -/** + * Opens the specified category, finds the first block of the given type, + * scrolls it into view, and returns a draggable element on that block. + * * @param browser The active WebdriverIO Browser object. * @param categoryName The name of the toolbox category to search. * Null if the toolbox has no categories (simple). * @param blockType The type of the block to search for. - * @return A Promise that resolves to the root element of the first + * @return A Promise that resolves to a draggable element of the first * block with the given type in the given category. */ export async function getBlockTypeFromCategory( @@ -290,61 +286,12 @@ export async function getBlockTypeFromCategory( await browser.pause(PAUSE_TIME); const id = await browser.execute((blockType) => { - return Blockly.getMainWorkspace() - .getFlyout() - .getWorkspace() - .getBlocksByType(blockType)[0].id; + const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace(); + const block = ws.getBlocksByType(blockType)[0]; + ws.scrollBoundsIntoView(block.getBoundingRectangleWithoutChildren()); + return block.id; }, blockType); - return getBlockElementById(browser, id); -} - -/** - * @param browser The active WebdriverIO Browser object. - * @param categoryName The name of the toolbox category to search. - * Null if the toolbox has no categories (simple). - * @param blockType The type of the block to search for. - * @return A Promise that resolves to a reasonable drag target element of the - * first block with the given type in the given category. - */ -export async function getDraggableBlockElementByType( - browser, - categoryName, - blockType, -) { - if (categoryName) { - const category = await getCategory(browser, categoryName); - await category.click(); - } - - const findableId = 'dragTargetElement'; - // In the browser context, find the element that we want and give it a findable ID. - await browser.execute( - (blockType, newElemId) => { - const block = Blockly.getMainWorkspace() - .getFlyout() - .getWorkspace() - .getBlocksByType(blockType)[0]; - if (!block.isCollapsed()) { - for (const input of block.inputList) { - for (const field of input.fieldRow) { - if (field instanceof Blockly.FieldLabel) { - const svgRoot = field.getSvgRoot(); - if (svgRoot) { - svgRoot.id = newElemId; - return; - } - } - } - } - } - // No label field found. Fall back to the block's SVG root. - block.getSvgRoot().id = newElemId; - }, - blockType, - findableId, - ); - // In the test context, get the Webdriverio Element that we've identified. - return await browser.$(`#${findableId}`); + return getTargetableBlockElement(browser, id, true); } /** @@ -499,10 +446,16 @@ export async function switchRTL(browser) { * created block. */ export async function dragNthBlockFromFlyout(browser, categoryName, n, x, y) { - const flyoutBlock = await getNthBlockOfCategory(browser, categoryName, n); - while (!(await elementInBounds(browser, flyoutBlock))) { - await scrollFlyout(browser, 0, 50); - } + const category = await getCategory(browser, categoryName); + await category.click(); + + await browser.pause(PAUSE_TIME); + const id = await browser.execute((n) => { + const ws = Blockly.getMainWorkspace().getFlyout().getWorkspace(); + const block = ws.getTopBlocks(true)[n]; + return block.id; + }, n); + const flyoutBlock = await getTargetableBlockElement(browser, id, true); await flyoutBlock.dragAndDrop({x: x, y: y}); return await getSelectedBlockElement(browser); } @@ -529,44 +482,16 @@ export async function dragBlockTypeFromFlyout( x, y, ) { - const flyoutBlock = await getDraggableBlockElementByType( + const flyoutBlock = await getBlockTypeFromCategory( browser, categoryName, type, ); - while (!(await elementInBounds(browser, flyoutBlock))) { - await scrollFlyout(browser, 0, 50); - } await flyoutBlock.dragAndDrop({x: x, y: y}); await browser.pause(PAUSE_TIME); return await getSelectedBlockElement(browser); } -/** - * Check whether an element is fully inside the bounds of the Blockly div. You can use this - * to determine whether a block on the workspace or flyout is inside the Blockly div. - * This does not check whether there are other Blockly elements (such as a toolbox or - * flyout) on top of the element. A partially visible block is considered out of bounds. - * @param browser The active WebdriverIO Browser object. - * @param element The element to look for. - * @returns A Promise resolving to true if the element is in bounds and false otherwise. - */ -async function elementInBounds(browser, element) { - return await browser.execute((elem) => { - const rect = elem.getBoundingClientRect(); - - const blocklyDiv = document.getElementsByClassName('blocklySvg')[0]; - const blocklyRect = blocklyDiv.getBoundingClientRect(); - - const vertInView = - rect.top >= blocklyRect.top && rect.bottom <= blocklyRect.bottom; - const horInView = - rect.left >= blocklyRect.left && rect.right <= blocklyRect.right; - - return vertInView && horInView; - }, element); -} - /** * Drags the specified block type from the mutator flyout of the given block * and returns the root element of the block. @@ -667,27 +592,4 @@ export async function getAllBlocks(browser) { id: block.id, })); }); -} - -/** - * Find the flyout's scrollbar and scroll by the specified amount. - * This makes several assumptions: - * - A flyout with a valid scrollbar exists, is open, and is in view. - * - The workspace has a trash can, which means it has a second (hidden) flyout. - * @param browser The active WebdriverIO Browser object. - * @param xDelta How far to drag the flyout in the x direction. Positive is right. - * @param yDelta How far to drag the flyout in the y direction. Positive is down. - * @return A Promise that resolves when the actions are completed. - */ -export async function scrollFlyout(browser, xDelta, yDelta) { - // There are two flyouts on the playground workspace: one for the trash can - // and one for the toolbox. We want the second one. - // This assumes there is only one scrollbar handle in the flyout, but it could - // be either horizontal or vertical. - await browser.pause(PAUSE_TIME); - const scrollbarHandle = await browser - .$$(`.blocklyFlyoutScrollbar`)[1] - .$(`rect.blocklyScrollbarHandle`); - await scrollbarHandle.dragAndDrop({x: xDelta, y: yDelta}); - await browser.pause(PAUSE_TIME); -} +} \ No newline at end of file diff --git a/tests/browser/test/toolbox_drag_test.mjs b/tests/browser/test/toolbox_drag_test.mjs index 22d1bb16579..98d865c1eeb 100644 --- a/tests/browser/test/toolbox_drag_test.mjs +++ b/tests/browser/test/toolbox_drag_test.mjs @@ -17,6 +17,7 @@ import { testFileLocations, testSetup, } from './test_setup.mjs'; +import {Key} from 'webdriverio'; // Categories in the basic toolbox. const basicCategories = [ @@ -77,7 +78,7 @@ async function getNthBlockType(browser, categoryName, n) { }, n); // Unicode escape to close flyout. - await browser.keys(['\uE00C']); + await browser.keys([Key.Escape]); await browser.pause(PAUSE_TIME); return blockType; } @@ -102,7 +103,7 @@ async function getBlockCount(browser, categoryName) { }); // Unicode escape to close flyout. - await browser.keys(['\uE00C']); + await browser.keys([Key.Escape]); await browser.pause(PAUSE_TIME); return blockCount; } @@ -142,7 +143,7 @@ async function openCategories(browser, categoryList, directionMultiplier) { await category.click(); if (await isBlockDisabled(browser, i)) { // Unicode escape to close flyout. - await browser.keys(['\uE00C']); + await browser.keys([Key.Escape]); await browser.pause(PAUSE_TIME); continue; } From 1e40641f452cefb1334b95dc0c21e93717170fd9 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Tue, 8 Jul 2025 14:35:28 -0700 Subject: [PATCH 63/67] Fix formatting --- tests/browser/test/test_setup.mjs | 2 +- tests/browser/test/toolbox_drag_test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/browser/test/test_setup.mjs b/tests/browser/test/test_setup.mjs index d91a109241d..6cf4986fce5 100644 --- a/tests/browser/test/test_setup.mjs +++ b/tests/browser/test/test_setup.mjs @@ -592,4 +592,4 @@ export async function getAllBlocks(browser) { id: block.id, })); }); -} \ No newline at end of file +} diff --git a/tests/browser/test/toolbox_drag_test.mjs b/tests/browser/test/toolbox_drag_test.mjs index 98d865c1eeb..be64c692047 100644 --- a/tests/browser/test/toolbox_drag_test.mjs +++ b/tests/browser/test/toolbox_drag_test.mjs @@ -9,6 +9,7 @@ */ import * as chai from 'chai'; +import {Key} from 'webdriverio'; import { dragBlockTypeFromFlyout, getCategory, @@ -17,7 +18,6 @@ import { testFileLocations, testSetup, } from './test_setup.mjs'; -import {Key} from 'webdriverio'; // Categories in the basic toolbox. const basicCategories = [ From 2fba036a8dd6ae9eec629b0b9f2b6e2437cf2087 Mon Sep 17 00:00:00 2001 From: Erik Pasternak Date: Tue, 8 Jul 2025 15:17:33 -0700 Subject: [PATCH 64/67] Add a todo for enabling the toolbox categories tests --- tests/browser/test/toolbox_drag_test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/browser/test/toolbox_drag_test.mjs b/tests/browser/test/toolbox_drag_test.mjs index be64c692047..32c20140692 100644 --- a/tests/browser/test/toolbox_drag_test.mjs +++ b/tests/browser/test/toolbox_drag_test.mjs @@ -174,8 +174,8 @@ async function openCategories(browser, categoryList, directionMultiplier) { chai.assert.equal(failureCount, 0); } -// These take too long to run and are very flakey. Need to find a better way to -// test whatever this is trying to test. +// TODO (#9217) These take too long to run and are very flakey. Need to find a +// better way to test whatever this is trying to test. suite.skip('Open toolbox categories', function () { this.timeout(0); From c0489b41e04b6ce616d44cebab6c3f73182f8478 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Tue, 8 Jul 2025 16:05:53 -0700 Subject: [PATCH 65/67] feat: add copy api and paste into correct workspace (#9215) * feat: add copy api and paste into correct workspace * fix: dont paste into unrendered workspaces * fix: paste precondition and add test --- core/clipboard.ts | 138 +++++++++++++++++++++++------ core/shortcut_items.ts | 42 +++++---- tests/mocha/clipboard_test.js | 35 +++++++- tests/mocha/shortcut_items_test.js | 14 +++ 4 files changed, 181 insertions(+), 48 deletions(-) diff --git a/core/clipboard.ts b/core/clipboard.ts index 5fa654d630c..c7b22dfc7a8 100644 --- a/core/clipboard.ts +++ b/core/clipboard.ts @@ -9,6 +9,7 @@ import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js'; import * as registry from './clipboard/registry.js'; import type {ICopyData, ICopyable} from './interfaces/i_copyable.js'; +import {isSelectable} from './interfaces/i_selectable.js'; import * as globalRegistry from './registry.js'; import {Coordinate} from './utils/coordinate.js'; import {WorkspaceSvg} from './workspace_svg.js'; @@ -18,18 +19,119 @@ let stashedCopyData: ICopyData | null = null; let stashedWorkspace: WorkspaceSvg | null = null; +let stashedCoordinates: Coordinate | undefined = undefined; + /** - * Private version of copy for stubbing in tests. + * Copy a copyable item, and record its data and the workspace it was + * copied from. + * + * This function does not perform any checks to ensure the copy + * should be allowed, e.g. to ensure the block is deletable. Such + * checks should be done before calling this function. + * + * Note that if the copyable item is not an `ISelectable` or its + * `workspace` property is not a `WorkspaceSvg`, the copy will be + * successful, but there will be no saved workspace data. This will + * impact the ability to paste the data unless you explictily pass + * a workspace into the paste method. + * + * @param toCopy item to copy. + * @param location location to save as a potential paste location. + * @returns the copied data if copy was successful, otherwise null. */ -function copyInternal(toCopy: ICopyable): T | null { +export function copy( + toCopy: ICopyable, + location?: Coordinate, +): T | null { const data = toCopy.toCopyData(); stashedCopyData = data; - stashedWorkspace = (toCopy as any).workspace ?? null; + if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) { + stashedWorkspace = toCopy.workspace; + } else { + stashedWorkspace = null; + } + + stashedCoordinates = location; return data; } /** - * Paste a pasteable element into the workspace. + * Gets the copy data for the last item copied. This is useful if you + * are implementing custom copy/paste behavior. If you want the default + * behavior, just use the copy and paste methods directly. + * + * @returns copy data for the last item copied, or null if none set. + */ +export function getLastCopiedData() { + return stashedCopyData; +} + +/** + * Sets the last copied item. You should call this method if you implement + * custom copy behavior, so that other callers are working with the correct + * data. This method is called automatically if you use the built-in copy + * method. + * + * @param copyData copy data for the last item copied. + */ +export function setLastCopiedData(copyData: ICopyData) { + stashedCopyData = copyData; +} + +/** + * Gets the workspace that was last copied from. This is useful if you + * are implementing custom copy/paste behavior and want to paste on the + * same workspace that was copied from. If you want the default behavior, + * just use the copy and paste methods directly. + * + * @returns workspace that was last copied from, or null if none set. + */ +export function getLastCopiedWorkspace() { + return stashedWorkspace; +} + +/** + * Sets the workspace that was last copied from. You should call this method + * if you implement custom copy behavior, so that other callers are working + * with the correct data. This method is called automatically if you use the + * built-in copy method. + * + * @param workspace workspace that was last copied from. + */ +export function setLastCopiedWorkspace(workspace: WorkspaceSvg) { + stashedWorkspace = workspace; +} + +/** + * Gets the location that was last copied from. This is useful if you + * are implementing custom copy/paste behavior. If you want the + * default behavior, just use the copy and paste methods directly. + * + * @returns last saved location, or null if none set. + */ +export function getLastCopiedLocation() { + return stashedCoordinates; +} + +/** + * Sets the location that was last copied from. You should call this method + * if you implement custom copy behavior, so that other callers are working + * with the correct data. This method is called automatically if you use the + * built-in copy method. + * + * @param location last saved location, which can be used to paste at. + */ +export function setLastCopiedLocation(location: Coordinate) { + stashedCoordinates = location; +} + +/** + * Paste a pasteable element into the given workspace. + * + * This function does not perform any checks to ensure the paste + * is allowed, e.g. that the workspace is rendered or the block + * is pasteable. Such checks should be done before calling this + * function. * * @param copyData The data to paste into the workspace. * @param workspace The workspace to paste the data into. @@ -43,7 +145,7 @@ export function paste( ): ICopyable | null; /** - * Pastes the last copied ICopyable into the workspace. + * Pastes the last copied ICopyable into the last copied-from workspace. * * @returns the pasted thing if the paste was successful, null otherwise. */ @@ -65,7 +167,7 @@ export function paste( ): ICopyable | null { if (!copyData || !workspace) { if (!stashedCopyData || !stashedWorkspace) return null; - return pasteFromData(stashedCopyData, stashedWorkspace); + return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates); } return pasteFromData(copyData, workspace, coordinate); } @@ -85,31 +187,11 @@ function pasteFromData( ): ICopyable | null { workspace = workspace.isMutator ? workspace - : (workspace.getRootWorkspace() ?? workspace); + : // Use the parent workspace if it exists (e.g. for pasting into flyouts) + (workspace.options.parentWorkspace ?? workspace); return (globalRegistry .getObject(globalRegistry.Type.PASTER, copyData.paster, false) ?.paste(copyData, workspace, coordinate) ?? null) as ICopyable | null; } -/** - * Private version of duplicate for stubbing in tests. - */ -function duplicateInternal< - U extends ICopyData, - T extends ICopyable & IHasWorkspace, ->(toDuplicate: T): T | null { - const data = toDuplicate.toCopyData(); - if (!data) return null; - return paste(data, toDuplicate.workspace) as T; -} - -interface IHasWorkspace { - workspace: WorkspaceSvg; -} - -export const TEST_ONLY = { - duplicateInternal, - copyInternal, -}; - export {BlockCopyData, BlockPaster, registry}; diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index f621f93d39f..f8c95500770 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -11,7 +11,7 @@ import * as clipboard from './clipboard.js'; import {RenderedWorkspaceComment} from './comments.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; -import {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.js'; +import {isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {isDraggable} from './interfaces/i_draggable.js'; import {IFocusableNode} from './interfaces/i_focusable_node.js'; @@ -92,9 +92,6 @@ export function registerDelete() { ShortcutRegistry.registry.register(deleteShortcut); } -let copyData: ICopyData | null = null; -let copyCoords: Coordinate | null = null; - /** * Determine if a focusable node can be copied. * @@ -175,12 +172,12 @@ export function registerCopy() { if (!focused.workspace.isFlyout) { targetWorkspace.hideChaff(); } - copyData = focused.toCopyData(); - copyCoords = + + const copyCoords = isDraggable(focused) && focused.workspace == targetWorkspace ? focused.getRelativeToSurfaceXY() - : null; - return !!copyData; + : undefined; + return !!clipboard.copy(focused, copyCoords); }, keyCodes: [ctrlC, metaC], }; @@ -215,10 +212,10 @@ export function registerCut() { if (!focused || !isCuttable(focused) || !isICopyable(focused)) { return false; } - copyData = focused.toCopyData(); - copyCoords = isDraggable(focused) + const copyCoords = isDraggable(focused) ? focused.getRelativeToSurfaceXY() - : null; + : undefined; + const copyData = clipboard.copy(focused, copyCoords); if (focused instanceof BlockSvg) { focused.checkAndDelete(); @@ -246,12 +243,19 @@ export function registerPaste() { const pasteShortcut: KeyboardShortcut = { name: names.PASTE, - preconditionFn(workspace) { + preconditionFn() { + // Regardless of the currently focused workspace, we will only + // paste into the last-copied-from workspace. + const workspace = clipboard.getLastCopiedWorkspace(); + // If we don't know where we copied from, we don't know where to paste. + // If the workspace isn't rendered (e.g. closed mutator workspace), + // we can't paste into it. + if (!workspace || !workspace.rendered) return false; const targetWorkspace = workspace.isFlyout ? workspace.targetWorkspace : workspace; return ( - !!copyData && + !!clipboard.getLastCopiedData() && !!targetWorkspace && !targetWorkspace.isReadOnly() && !targetWorkspace.isDragging() && @@ -259,10 +263,15 @@ export function registerPaste() { ); }, callback(workspace: WorkspaceSvg, e: Event) { + const copyData = clipboard.getLastCopiedData(); if (!copyData) return false; - const targetWorkspace = workspace.isFlyout - ? workspace.targetWorkspace - : workspace; + + const copyWorkspace = clipboard.getLastCopiedWorkspace(); + if (!copyWorkspace) return false; + + const targetWorkspace = copyWorkspace.isFlyout + ? copyWorkspace.targetWorkspace + : copyWorkspace; if (!targetWorkspace || targetWorkspace.isReadOnly()) return false; if (e instanceof PointerEvent) { @@ -278,6 +287,7 @@ export function registerPaste() { return !!clipboard.paste(copyData, targetWorkspace, mouseCoords); } + const copyCoords = clipboard.getLastCopiedLocation(); if (!copyCoords) { // If we don't have location data about the original copyable, let the // paster determine position. diff --git a/tests/mocha/clipboard_test.js b/tests/mocha/clipboard_test.js index 0f2d067708a..85cdd229777 100644 --- a/tests/mocha/clipboard_test.js +++ b/tests/mocha/clipboard_test.js @@ -76,7 +76,7 @@ suite('Clipboard', function () { await mutatorIcon.setBubbleVisible(true); const mutatorWorkspace = mutatorIcon.getWorkspace(); const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0]; - assert.notEqual(elseIf, undefined); + assert.isDefined(elseIf); assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2); assert.lengthOf(this.workspace.getAllBlocks(), 1); const data = elseIf.toCopyData(); @@ -85,6 +85,34 @@ suite('Clipboard', function () { assert.lengthOf(this.workspace.getAllBlocks(), 1); }); + test('pasting into a mutator flyout pastes into the mutator workspace', async function () { + const block = Blockly.serialization.blocks.append( + { + 'type': 'controls_if', + 'id': 'blockId', + 'extraState': { + 'elseIfCount': 1, + }, + }, + this.workspace, + ); + const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR); + await mutatorIcon.setBubbleVisible(true); + const mutatorWorkspace = mutatorIcon.getWorkspace(); + const mutatorFlyoutWorkspace = mutatorWorkspace + .getFlyout() + .getWorkspace(); + const elseIf = + mutatorFlyoutWorkspace.getBlocksByType('controls_if_elseif')[0]; + assert.isDefined(elseIf); + assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2); + assert.lengthOf(this.workspace.getAllBlocks(), 1); + const data = elseIf.toCopyData(); + Blockly.clipboard.paste(data, mutatorFlyoutWorkspace); + assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3); + assert.lengthOf(this.workspace.getAllBlocks(), 1); + }); + suite('pasted blocks are placed in unambiguous locations', function () { test('pasted blocks are bumped to not overlap', function () { const block = Blockly.serialization.blocks.append( @@ -139,8 +167,7 @@ suite('Clipboard', function () { }); suite('pasting comments', function () { - // TODO: Reenable test when we readd copy-paste. - test.skip('pasted comments are bumped to not overlap', function () { + test('pasted comments are bumped to not overlap', function () { Blockly.Xml.domToWorkspace( Blockly.utils.xml.textToDom( '', @@ -153,7 +180,7 @@ suite('Clipboard', function () { const newComment = Blockly.clipboard.paste(data, this.workspace); assert.deepEqual( newComment.getRelativeToSurfaceXY(), - new Blockly.utils.Coordinate(60, 60), + new Blockly.utils.Coordinate(40, 40), ); }); }); diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index eaadef01eb1..d96ddbfeadc 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -5,6 +5,7 @@ */ import * as Blockly from '../../build/src/core/blockly.js'; +import {assert} from '../../node_modules/chai/chai.js'; import {defineStackBlock} from './test_helpers/block_definitions.js'; import { sharedTestSetup, @@ -399,6 +400,19 @@ suite('Keyboard Shortcut Items', function () { }); }); + suite('Paste', function () { + test('Disabled when nothing has been copied', function () { + const pasteShortcut = + Blockly.ShortcutRegistry.registry.getRegistry()[ + Blockly.ShortcutItems.names.PASTE + ]; + Blockly.clipboard.setLastCopiedData(undefined); + + const isPasteEnabled = pasteShortcut.preconditionFn(); + assert.isFalse(isPasteEnabled); + }); + }); + suite('Undo', function () { setup(function () { this.undoSpy = sinon.spy(this.workspace, 'undo'); From bea183d85da6465cd0511fc48f2e612b99b6c1ff Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 8 Jul 2025 16:06:24 -0700 Subject: [PATCH 66/67] fix: Auto-close widget divs on lost focus (#9216) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes https://github.com/google/blockly-keyboard-experimentation/issues/563 ### Proposed Changes This expands the functionality introduced in #9213 to also include widget divs. ### Reason for Changes MakeCode makes use of widget div in several field editors, so the issues described in https://github.com/google/blockly-keyboard-experimentation/issues/563 aren't fully mitigated with #9213 alone. This PR essentially adds the same support for auto-closing as drop-down divs now have, and enables this functionality by default. Note the drop-down div change: it was missed in #9123 that the API change for drop-down div's `show` function is actually API-breaking, so this updates that API to be properly backward compatible (and reverts one test change that makes use of it). The `FocusManager` change actually corrects an implementation issue from #9123: not updating the tracked focus status before calling the callback can result in focus being inadvertently restored if the callback triggers returning focus due to a lost focus situation. This was wrong for drop-down divs, too, but it's harder to notice there because the dismissal of the drop-down div happens on a timer (which means there's sufficient time for `FocusManager`'s state to correct prior to attempting to return from the ephemeral focus state). Demonstration of fixed behavior (since the inline number editor uses a widget div): [Screen recording 2025-07-08 2.12.31 PM.webm](https://github.com/user-attachments/assets/7c3c7c3c-224c-48f4-b4af-bde86feecfa8) ### Test Coverage New widget div tests have been added to verify the new parameter and auto-close functionality. The `FocusManager` test was updated to account for the new, and correct, behavior around the internal tracked ephemeral focus state. Note that some `tabindex` state has been clarified and cleaned up in the test index page and `FocusManager`. It's fine (and preferable) for ephemeral-used elements to always be focusable rather than making them dynamically so (which avoids state bleed across test runs which was happening one of the new tests). https://github.com/google/blockly-keyboard-experimentation/pull/649 includes additional tests for validating widget behaviors. ### Documentation No new documentation should be needed here--the API documentation changes should be sufficient. One documentation update was made in `dropdowndiv.ts` that corrects the documentation parameter ordering. ### Additional Information Nothing further to add. --- core/dropdowndiv.ts | 6 +-- core/focus_manager.ts | 2 +- core/widgetdiv.ts | 16 +++++++- tests/mocha/dropdowndiv_test.js | 4 +- tests/mocha/focus_manager_test.js | 39 +++++++----------- tests/mocha/index.html | 2 +- tests/mocha/widget_div_test.js | 66 +++++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 33 deletions(-) diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index 608fe9b5b2c..f305778222f 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -346,8 +346,8 @@ function showPositionedByRect( secondaryX, secondaryY, manageEphemeralFocus, - autoCloseOnLostFocus, opt_onHide, + autoCloseOnLostFocus, ); } @@ -366,9 +366,9 @@ function showPositionedByRect( * @param primaryY Desired origin point y, in absolute px. * @param secondaryX Secondary/alternative origin point x, in absolute px. * @param secondaryY Secondary/alternative origin point y, in absolute px. - * @param opt_onHide Optional callback for when the drop-down is hidden. * @param manageEphemeralFocus Whether ephemeral focus should be managed * according to the widget div's lifetime. + * @param opt_onHide Optional callback for when the drop-down is hidden. * @param autoCloseOnLostFocus Whether the drop-down should automatically hide * if it loses DOM focus for any reason. * @returns True if the menu rendered at the primary origin point. @@ -382,8 +382,8 @@ export function show( secondaryX: number, secondaryY: number, manageEphemeralFocus: boolean, - autoCloseOnLostFocus: boolean, opt_onHide?: () => void, + autoCloseOnLostFocus?: boolean, ): boolean { owner = newOwner as Field; onHide = opt_onHide || null; diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 31453b827b5..1510afca0c5 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -138,10 +138,10 @@ export class FocusManager { element instanceof Node && ephemeralFocusElem.contains(element); if (hadFocus !== hasFocus) { + this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus; if (this.ephemeralDomFocusChangedCallback) { this.ephemeralDomFocusChangedCallback(hasFocus); } - this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus; } } }; diff --git a/core/widgetdiv.ts b/core/widgetdiv.ts index f9b89de56d2..e0d4e1229df 100644 --- a/core/widgetdiv.ts +++ b/core/widgetdiv.ts @@ -99,6 +99,8 @@ export function createDom() { * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. + * @param autoCloseOnLostFocus Whether the widget should automatically hide if + * it loses DOM focus for any reason. */ export function show( newOwner: unknown, @@ -106,6 +108,7 @@ export function show( newDispose: () => void, workspace?: WorkspaceSvg | null, manageEphemeralFocus: boolean = true, + autoCloseOnLostFocus: boolean = true, ) { hide(); owner = newOwner; @@ -131,7 +134,18 @@ export function show( dom.addClass(div, themeClassName); } if (manageEphemeralFocus) { - returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + const autoCloseCallback = autoCloseOnLostFocus + ? (hasFocus: boolean) => { + // If focus is ever lost, close the widget. + if (!hasFocus) { + hide(); + } + } + : null; + returnEphemeralFocus = getFocusManager().takeEphemeralFocus( + div, + autoCloseCallback, + ); } } diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index fac8368a952..9774ed0249d 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -155,7 +155,7 @@ suite('DropDownDiv', function () { }); test('Escape dismisses DropDownDiv', function () { let hidden = false; - Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, false, () => { + Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, () => { hidden = true; }); assert.isFalse(hidden); @@ -276,7 +276,7 @@ suite('DropDownDiv', function () { // Focus an element outside of the drop-down. document.getElementById('nonTreeElementForEphemeralFocus').focus(); - // the drop-down should now be hidden since it lost focus. + // The drop-down should now be hidden since it lost focus. const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); assert.strictEqual(dropDownDivElem.style.opacity, '0'); }); diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index cb4a43652fe..2544b44db0e 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -5624,21 +5624,6 @@ suite('FocusManager', function () { /* Ephemeral focus tests. */ suite('takeEphemeralFocus()', function () { - setup(function () { - // Ensure ephemeral-specific elements are focusable. - document.getElementById('nonTreeElementForEphemeralFocus').tabIndex = -1; - document.getElementById('nonTreeGroupForEphemeralFocus').tabIndex = -1; - }); - teardown(function () { - // Ensure ephemeral-specific elements have their tab indexes reset for a clean state. - document - .getElementById('nonTreeElementForEphemeralFocus') - .removeAttribute('tabindex'); - document - .getElementById('nonTreeGroupForEphemeralFocus') - .removeAttribute('tabindex'); - }); - test('with no focused node does not change states', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); @@ -6070,7 +6055,7 @@ suite('FocusManager', function () { assert.isTrue(callback.thirdCall.calledWithExactly(true)); }); - test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral', function () { + test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral does not restore to focused node', function () { this.focusManager.registerTree(this.testFocusableTree2); this.focusManager.registerTree(this.testFocusableGroup2); this.focusManager.focusNode(this.testFocusableTree2Node1); @@ -6090,21 +6075,24 @@ suite('FocusManager', function () { // Force focus away, triggering the callback's automatic returning logic. ephemeralElement2.focus(); - // The original focused node should be restored. - const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + // The original node should not be focused since the ephemeral element + // lost its own DOM focus while ephemeral focus was active. Instead, the + // newly active element should still hold focus. const activeElems = Array.from( document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), ); - assert.strictEqual( - this.focusManager.getFocusedNode(), - this.testFocusableTree2Node1, + const passiveElems = Array.from( + document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), ); - assert.strictEqual(activeElems.length, 1); + assert.isEmpty(activeElems); + assert.strictEqual(passiveElems.length, 1); assert.includesClass( - nodeElem.classList, - FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + this.testFocusableTree2Node1.getFocusableElement().classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, ); - assert.strictEqual(document.activeElement, nodeElem); + assert.isNull(this.focusManager.getFocusedNode()); + assert.strictEqual(document.activeElement, ephemeralElement2); + assert.isFalse(this.focusManager.ephemeralFocusTaken()); }); test('with focus on non-ephemeral element ephemeral ended does not restore to focused node', function () { @@ -6139,6 +6127,7 @@ suite('FocusManager', function () { this.testFocusableTree2Node1.getFocusableElement().classList, FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, ); + assert.isNull(this.focusManager.getFocusedNode()); assert.strictEqual(document.activeElement, ephemeralElement2); assert.isFalse(this.focusManager.ephemeralFocusTaken()); }); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index fea0fb18e84..81618a4f812 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -142,7 +142,7 @@ - +