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/30] 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 f313dcf8b..d37ddd90c 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/30] 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 d37ddd90c..f622d64df 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/30] 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 f622d64df..40c20bd0d 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/30] 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 8ea26e354..501be1b59 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 3a3d57a44..569905518 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 b653bd20a..4f5e4ab9a 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 161d5fceb..0da30de62 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/30] 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 0da30de62..8d67321d7 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/30] 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 501be1b59..a30cc34ed 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 569905518..42fb1fda4 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 4f5e4ab9a..246dd4dd5 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 8d67321d7..302308fd6 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/30] 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 43bc6bbc5..9f7c11d4f 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 190efd64d..b5dc3023c 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/30] 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 302308fd6..826cef285 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/30] 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 826cef285..615f1edc2 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/30] 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 615f1edc2..dca2d7366 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 622df9efc..7667ba387 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/30] 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 85c0f414a..89668dedb 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/30] 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 7667ba387..4ab83d8e1 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/30] 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 570b06fe3..2637ad49d 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 1d283f331..6f841ae09 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/30] 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 40c20bd0d..ec6db288c 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/30] 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 dca2d7366..25295e417 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/30] 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 246dd4dd5..6c354926a 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/30] 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 a4b198ae4..7f23779ec 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/30] 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 6281ad758..4946ee458 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/30] 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 ec6db288c..20311ab05 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/30] 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 20311ab05..d014f6240 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/30] 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 f6ea60936..194cb41f3 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 1842855fa..9055a91ea 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 96908cbbd..70631ce81 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 12a7555b4..b671f8fe7 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 92c921122..77bb64cd8 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 9eb5ea545..552d37061 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/30] 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 20311ab05..47f36c5ab 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/30] 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 47f36c5ab..7aa9dac8e 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/30] 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 e7c468d28..5494d7fb0 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 5436da59f..5e03efc41 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 d0c3e1768..b7611b484 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/30] 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 000000000..753d31dda --- /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/30] 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 3810c4ef4..8f8de5349 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/30] 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 ee8591987..86e8f50b9 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 000000000..f921168fa --- /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 26623d40f..1e5ad4a52 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 42fb1fda4..3457e611a 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 552d37061..3033eacd7 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/30] 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 41e761f57..1193023d2 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 05f86f40f..1ab5bead4 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 6c354926a..8d1853967 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 046770940..156e43ddc 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 cb723e7b8..913038116 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 00557168a..24833328d 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 f4f25f7f5..c33189fcd 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 85c6f0990..0c2e257a4 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 74489dc5e..06f416424 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 d74eaec22..c723a5ed7 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 96a2a0bc4..8db0c2378 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 321ff118f..128913a26 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 f85380527..3a6dc4847 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 fe9460c7f..2f82487e9 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 639972e45..5374f50cd 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 380a27709..99e597da3 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 eda2d82a5..62c61ce00 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 5d117c712..0e549b976 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/30] 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 25295e417..062d0cb4e 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/30] 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 4946ee458..7479c06cf 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 f921168fa..9a1907e91 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(); }, );