From 53b6362c2fe19463bb36e755b90350d6854530a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 08:26:04 -0700 Subject: [PATCH 01/17] chore(deps): bump eslint from 9.26.0 to 9.30.0 (#9186) Bumps [eslint](https://github.com/eslint/eslint) from 9.26.0 to 9.30.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v9.26.0...v9.30.0) --- updated-dependencies: - dependency-name: eslint dependency-version: 9.30.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 716 ++++------------------------------------------ 1 file changed, 60 insertions(+), 656 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8f8de5349..779dc904f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -428,11 +428,10 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", @@ -443,21 +442,19 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz", - "integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", - "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -503,13 +500,15 @@ } }, "node_modules/@eslint/js": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", - "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", + "version": "9.30.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz", + "integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -517,25 +516,35 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", - "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.13.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@gulp-sourcemaps/identity-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz", @@ -1144,28 +1153,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.3", - "eventsource": "^3.0.2", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2029,24 +2016,10 @@ "node": ">=6.5" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -2831,27 +2804,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.0", - "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3365,40 +3317,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -3417,26 +3335,6 @@ "safe-buffer": "~5.1.1" } }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/copy-props": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/copy-props/-/copy-props-4.0.0.tgz", @@ -3456,20 +3354,6 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/corser": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", @@ -3803,16 +3687,6 @@ "node": ">= 14" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4008,29 +3882,12 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/encoding-sniffer": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", @@ -4162,13 +4019,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4215,24 +4065,22 @@ } }, "node_modules/eslint": { - "version": "9.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", - "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", + "version": "9.30.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz", + "integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.13.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.26.0", - "@eslint/plugin-kit": "^0.2.8", + "@eslint/js": "9.30.0", + "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", - "@modelcontextprotocol/sdk": "^1.8.0", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", @@ -4240,9 +4088,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4256,8 +4104,7 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "zod": "^3.24.2" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" @@ -4382,11 +4229,10 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -4411,9 +4257,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4435,14 +4281,14 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4452,9 +4298,9 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4493,7 +4339,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -4519,16 +4364,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -4563,29 +4398,6 @@ "node": ">=0.8.x" } }, - "node_modules/eventsource": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", - "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", - "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -4598,65 +4410,6 @@ "node": ">=0.10.0" } }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", - "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": "^4.11 || 5 || ^5.0.0-beta.1" - } - }, "node_modules/ext": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", @@ -4870,24 +4623,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5051,26 +4786,6 @@ "node": ">=12.20.0" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -6085,23 +5800,6 @@ "entities": "^4.5.0" } }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/http-proxy": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", @@ -6311,16 +6009,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -7119,16 +6807,6 @@ "node": ">= 0.4" } }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/memoizee": { "version": "0.4.15", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", @@ -7151,19 +6829,6 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7200,29 +6865,6 @@ "node": ">=4" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7464,16 +7106,6 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -7631,19 +7263,6 @@ "node": ">=0.10.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7880,16 +7499,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/patch-package": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", @@ -8106,16 +7715,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/plugin-error": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", @@ -8279,20 +7878,6 @@ "node": ">=0.4.0" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -8407,32 +7992,6 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8778,40 +8337,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -8922,29 +8447,6 @@ "node": ">= 10.13.0" } }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/serialize-error": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-11.0.3.tgz", @@ -8969,35 +8471,12 @@ "randombytes": "^2.1.0" } }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "dev": true }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -9257,16 +8736,6 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/stream-composer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", @@ -9661,16 +9130,6 @@ "node": ">=10.13.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -9762,21 +9221,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -9899,16 +9343,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -9973,16 +9407,6 @@ "node": ">= 10.13.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vinyl": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", @@ -10610,26 +10034,6 @@ "dependencies": { "safe-buffer": "~5.2.0" } - }, - "node_modules/zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", - "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } } } } From 6a04d0eadbf031a8ce689a81d7910adf1ae68d7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 09:19:06 -0700 Subject: [PATCH 02/17] chore(deps): bump eslint-plugin-jsdoc from 50.7.1 to 51.3.1 (#9191) Bumps [eslint-plugin-jsdoc](https://github.com/gajus/eslint-plugin-jsdoc) from 50.7.1 to 51.3.1. - [Release notes](https://github.com/gajus/eslint-plugin-jsdoc/releases) - [Changelog](https://github.com/gajus/eslint-plugin-jsdoc/blob/main/.releaserc) - [Commits](https://github.com/gajus/eslint-plugin-jsdoc/compare/v50.7.1...v51.3.1) --- updated-dependencies: - dependency-name: eslint-plugin-jsdoc dependency-version: 51.3.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 49 ++++++++++++++++++++++++++++------------------- package.json | 2 +- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 779dc904f..8696a8418 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "eslint": "^9.15.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.1", - "eslint-plugin-jsdoc": "^50.5.0", + "eslint-plugin-jsdoc": "^51.3.1", "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", @@ -383,20 +383,32 @@ } }, "node_modules/@es-joy/jsdoccomment": { - "version": "0.50.2", - "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz", - "integrity": "sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==", + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.52.0.tgz", + "integrity": "sha512-BXuN7BII+8AyNtn57euU2Yxo9yA/KUDNzrpXyi3pfqKmBhhysR6ZWOebFh3vyPoqA3/j1SOvGgucElMGwlXing==", "dev": true, - "license": "MIT", "dependencies": { - "@types/estree": "^1.0.6", - "@typescript-eslint/types": "^8.11.0", + "@types/estree": "^1.0.8", + "@typescript-eslint/types": "^8.34.1", "comment-parser": "1.4.1", "esquery": "^1.6.0", "jsdoc-type-pratt-parser": "~4.1.0" }, "engines": { - "node": ">=18" + "node": ">=20.11.0" + } + }, + "node_modules/@es-joy/jsdoccomment/node_modules/@typescript-eslint/types": { + "version": "8.35.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz", + "integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1466,9 +1478,9 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, "node_modules/@types/expect": { @@ -3133,7 +3145,6 @@ "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 12.0.0" } @@ -4150,25 +4161,24 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "50.7.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.7.1.tgz", - "integrity": "sha512-XBnVA5g2kUVokTNUiE1McEPse5n9/mNUmuJcx52psT6zBs2eVcXSmQBvjfa7NZdfLVSy3u1pEDDUxoxpwy89WA==", + "version": "51.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-51.3.1.tgz", + "integrity": "sha512-9v/e6XyrLf1HIs/uPCgm3GcUpH4BeuGVZJk7oauKKyS7su7d5Q6zx4Fq6TiYh+w7+b4Svy7ZWVCcNZJNx3y52w==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { - "@es-joy/jsdoccomment": "~0.50.2", + "@es-joy/jsdoccomment": "~0.52.0", "are-docs-informative": "^0.0.2", "comment-parser": "1.4.1", "debug": "^4.4.1", "escape-string-regexp": "^4.0.0", - "espree": "^10.3.0", + "espree": "^10.4.0", "esquery": "^1.6.0", "parse-imports-exports": "^0.2.4", "semver": "^7.7.2", "spdx-expression-parse": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=20.11.0" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" @@ -6314,7 +6324,6 @@ "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz", "integrity": "sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==", "dev": true, - "license": "MIT", "engines": { "node": ">=12.0.0" } diff --git a/package.json b/package.json index eab39b16c..c4e83340d 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "eslint": "^9.15.0", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^10.1.1", - "eslint-plugin-jsdoc": "^50.5.0", + "eslint-plugin-jsdoc": "^51.3.1", "eslint-plugin-prettier": "^5.2.1", "glob": "^11.0.1", "globals": "^16.0.0", From 9424deb06ab7cccfdc6e750daa72bf4b8af7f9aa Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Mon, 30 Jun 2025 09:32:08 -0700 Subject: [PATCH 03/17] build: Refactor gulpfiles from CJS to ESM (#9149) * refactor(build): Rename "package" gulp task (but not npm script) to "pack" This is to avoid an issue due to "package" being a reserved word in JavaScript, and therefore not a valid export identifier. * refactor(build): Convert gulpfile.js from CJS to ESM. * refactor(build): Convert scripts/gulpfiles/*.js from CJS to ESM * fix(build): Fix eslint warning for @license tag in gulpfile.mjs * chore(build): Remove unused imports * fix(build): Fix incorrect import of gulp-gzip * fix(build): Fix incorrect sourcemaps import reference --- eslint.config.mjs | 2 +- gulpfile.js | 54 ----------- gulpfile.mjs | 95 +++++++++++++++++++ package.json | 2 +- ...appengine_tasks.js => appengine_tasks.mjs} | 31 +++--- .../{build_tasks.js => build_tasks.mjs} | 84 ++++++++-------- scripts/gulpfiles/{config.js => config.mjs} | 14 +-- .../{docs_tasks.js => docs_tasks.mjs} | 15 ++- .../gulpfiles/{git_tasks.js => git_tasks.mjs} | 34 ++----- scripts/gulpfiles/helper_tasks.js | 19 ---- scripts/gulpfiles/helper_tasks.mjs | 25 +++++ .../{package_tasks.js => package_tasks.mjs} | 47 ++++----- .../{release_tasks.js => release_tasks.mjs} | 37 +++----- .../{test_tasks.js => test_tasks.mjs} | 30 +++--- 14 files changed, 251 insertions(+), 238 deletions(-) delete mode 100644 gulpfile.js create mode 100644 gulpfile.mjs rename scripts/gulpfiles/{appengine_tasks.js => appengine_tasks.mjs} (86%) rename scripts/gulpfiles/{build_tasks.js => build_tasks.mjs} (92%) rename scripts/gulpfiles/{config.js => config.mjs} (70%) rename scripts/gulpfiles/{docs_tasks.js => docs_tasks.mjs} (94%) rename scripts/gulpfiles/{git_tasks.js => git_tasks.mjs} (86%) delete mode 100644 scripts/gulpfiles/helper_tasks.js create mode 100644 scripts/gulpfiles/helper_tasks.mjs rename scripts/gulpfiles/{package_tasks.js => package_tasks.mjs} (89%) rename scripts/gulpfiles/{release_tasks.js => release_tasks.mjs} (87%) rename scripts/gulpfiles/{test_tasks.js => test_tasks.mjs} (94%) diff --git a/eslint.config.mjs b/eslint.config.mjs index 68f25133f..f018e525d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -184,7 +184,7 @@ export default [ files: [ 'eslint.config.mjs', '.prettierrc.js', - 'gulpfile.js', + 'gulpfile.mjs', 'scripts/helpers.js', 'tests/mocha/.mocharc.js', 'tests/migration/validate-renamings.mjs', diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index d2ad650c6..000000000 --- a/gulpfile.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @license - * Copyright 2018 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Gulp script to build Blockly for Node & NPM. - * Run this script by calling "npm install" in this directory. - */ -/* eslint-env node */ - -const gulp = require('gulp'); - -const buildTasks = require('./scripts/gulpfiles/build_tasks'); -const packageTasks = require('./scripts/gulpfiles/package_tasks'); -const gitTasks = require('./scripts/gulpfiles/git_tasks'); -const appengineTasks = require('./scripts/gulpfiles/appengine_tasks'); -const releaseTasks = require('./scripts/gulpfiles/release_tasks'); -const docsTasks = require('./scripts/gulpfiles/docs_tasks'); -const testTasks = require('./scripts/gulpfiles/test_tasks'); - -module.exports = { - // Default target if gulp invoked without specifying. - default: buildTasks.build, - - // Main sequence targets. They already invoke prerequisites. - langfiles: buildTasks.langfiles, // Build build/msg/*.js from msg/json/*. - tsc: buildTasks.tsc, - deps: buildTasks.deps, - minify: buildTasks.minify, - build: buildTasks.build, - package: packageTasks.package, - publish: releaseTasks.publish, - publishBeta: releaseTasks.publishBeta, - prepareDemos: appengineTasks.prepareDemos, - deployDemos: appengineTasks.deployDemos, - deployDemosBeta: appengineTasks.deployDemosBeta, - gitUpdateGithubPages: gitTasks.updateGithubPages, - - // Manually-invokable targets, with prerequisites where required. - messages: buildTasks.messages, // Generate msg/json/en.json et al. - clean: gulp.parallel(buildTasks.cleanBuildDir, packageTasks.cleanReleaseDir), - test: testTasks.test, - testGenerators: testTasks.generators, - buildAdvancedCompilationTest: buildTasks.buildAdvancedCompilationTest, - gitCreateRC: gitTasks.createRC, - docs: docsTasks.docs, - - // Legacy targets, to be deleted. - recompile: releaseTasks.recompile, - gitSyncDevelop: gitTasks.syncDevelop, - gitSyncMaster: gitTasks.syncMaster, -}; diff --git a/gulpfile.mjs b/gulpfile.mjs new file mode 100644 index 000000000..fd3de3bde --- /dev/null +++ b/gulpfile.mjs @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Gulp script to build Blockly for Node & NPM. + * Run this script by calling "npm install" in this directory. + */ +/* eslint-env node */ + +// Needed to prevent prettier from munging exports order, due to +// https://github.com/simonhaenisch/prettier-plugin-organize-imports/issues/146 +// - but has the unfortunate side effect of suppressing ordering of +// imports too: +// +// organize-imports-ignore + +import {parallel} from 'gulp'; +import { + deployDemos, + deployDemosBeta, + prepareDemos, +} from './scripts/gulpfiles/appengine_tasks.mjs'; +import { + build, + buildAdvancedCompilationTest, + cleanBuildDir, + langfiles, + messages, + minify, + tsc, +} from './scripts/gulpfiles/build_tasks.mjs'; +import {docs} from './scripts/gulpfiles/docs_tasks.mjs'; +import { + createRC, + syncDevelop, + syncMaster, + updateGithubPages, +} from './scripts/gulpfiles/git_tasks.mjs'; +import {cleanReleaseDir, pack} from './scripts/gulpfiles/package_tasks.mjs'; +import { + publish, + publishBeta, + recompile, +} from './scripts/gulpfiles/release_tasks.mjs'; +import {generators, test} from './scripts/gulpfiles/test_tasks.mjs'; + +const clean = parallel(cleanBuildDir, cleanReleaseDir); + +// Default target if gulp invoked without specifying. +export default build; + +// Main sequence targets. They already invoke prerequisites. Listed +// in typical order of invocation, and strictly listing prerequisites +// before dependants. +// +// prettier-ignore +export { + langfiles, + tsc, + minify, + build, + pack, // Formerly package. + publishBeta, + publish, + prepareDemos, + deployDemosBeta, + deployDemos, + updateGithubPages as gitUpdateGithubPages, +} + +// Manually-invokable targets that also invoke prerequisites where +// required. +// +// prettier-ignore +export { + messages, // Generate msg/json/en.json et al. + clean, + test, + generators as testGenerators, + buildAdvancedCompilationTest, + createRC as gitCreateRC, + docs, +} + +// Legacy targets, to be deleted. +// +// prettier-ignore +export { + recompile, + syncDevelop as gitSyncDevelop, + syncMaster as gitSyncMaster, +} diff --git a/package.json b/package.json index c4e83340d..6ed5e4ea4 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "lint:fix": "eslint . --fix", "langfiles": "gulp langfiles", "minify": "gulp minify", - "package": "gulp package", + "package": "gulp pack", "postinstall": "patch-package", "prepareDemos": "gulp prepareDemos", "publish": "npm ci && gulp publish", diff --git a/scripts/gulpfiles/appengine_tasks.js b/scripts/gulpfiles/appengine_tasks.mjs similarity index 86% rename from scripts/gulpfiles/appengine_tasks.js rename to scripts/gulpfiles/appengine_tasks.mjs index ddbd2f45f..754534383 100644 --- a/scripts/gulpfiles/appengine_tasks.js +++ b/scripts/gulpfiles/appengine_tasks.mjs @@ -8,16 +8,16 @@ * @fileoverview Gulp script to deploy Blockly demos on appengine. */ -const gulp = require('gulp'); +import * as gulp from 'gulp'; -const fs = require('fs'); -const path = require('path'); -const execSync = require('child_process').execSync; -const buildTasks = require('./build_tasks.js'); -const packageTasks = require('./package_tasks.js'); -const {rimraf} = require('rimraf'); +import * as fs from 'fs'; +import * as path from 'path'; +import {execSync} from 'child_process'; +import * as buildTasks from './build_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; +import {rimraf} from 'rimraf'; -const packageJson = require('../../package.json'); const demoTmpDir = '../_deploy'; const demoStaticTmpDir = '../_deploy/static'; @@ -123,7 +123,7 @@ function deployToAndClean(demoVersion) { */ function getDemosVersion() { // Replace all '.' with '-' e.g. 9-3-3-beta-2 - return packageJson.version.replace(/\./g, '-'); + return getPackageJson().version.replace(/\./g, '-'); } /** @@ -162,7 +162,7 @@ function deployBetaAndClean(done) { * * Prerequisites (invoked): clean, build */ -const prepareDemos = gulp.series( +export const prepareDemos = gulp.series( prepareDeployDir, gulp.parallel( gulp.series( @@ -180,16 +180,9 @@ const prepareDemos = gulp.series( /** * Deploys demos. */ -const deployDemos = gulp.series(prepareDemos, deployAndClean); +export const deployDemos = gulp.series(prepareDemos, deployAndClean); /** * Deploys beta version of demos (version appended with -beta). */ -const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - deployDemos: deployDemos, - deployDemosBeta: deployDemosBeta, - prepareDemos: prepareDemos -}; +export const deployDemosBeta = gulp.series(prepareDemos, deployBetaAndClean); diff --git a/scripts/gulpfiles/build_tasks.js b/scripts/gulpfiles/build_tasks.mjs similarity index 92% rename from scripts/gulpfiles/build_tasks.js rename to scripts/gulpfiles/build_tasks.mjs index a00c1b17d..669e73258 100644 --- a/scripts/gulpfiles/build_tasks.js +++ b/scripts/gulpfiles/build_tasks.mjs @@ -8,25 +8,32 @@ * @fileoverview Gulp script to build Blockly for Node & NPM. */ -const gulp = require('gulp'); -gulp.replace = require('gulp-replace'); -gulp.rename = require('gulp-rename'); -gulp.sourcemaps = require('gulp-sourcemaps'); +import * as gulp from 'gulp'; +import replace from 'gulp-replace'; +import rename from 'gulp-rename'; +import sourcemaps from 'gulp-sourcemaps'; -const path = require('path'); -const fs = require('fs'); -const fsPromises = require('fs/promises'); -const {exec, execSync} = require('child_process'); +import * as path from 'path'; +import * as fs from 'fs'; +import * as fsPromises from 'fs/promises'; +import {exec, execSync} from 'child_process'; -const {globSync} = require('glob'); -const closureCompiler = require('google-closure-compiler').gulp(); -const argv = require('yargs').argv; -const {rimraf} = require('rimraf'); +import {globSync} from 'glob'; +// For v20250609.0.0 and later: +// import {gulp as closureCompiler} from 'google-closure-compiler'; +import ClosureCompiler from 'google-closure-compiler'; +import yargs from 'yargs'; +import {hideBin} from 'yargs/helpers'; +import {rimraf} from 'rimraf'; -const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} = require('./config'); -const {getPackageJson} = require('./helper_tasks'); +import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TSC_OUTPUT_DIR, TYPINGS_BUILD_DIR} from './config.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; -const {posixPath, quote} = require('../helpers'); +import {posixPath, quote} from '../helpers.js'; + +const closureCompiler = ClosureCompiler.gulp(); + +const argv = yargs(hideBin(process.argv)).parse(); //////////////////////////////////////////////////////////// // Build // @@ -182,7 +189,7 @@ function stripApacheLicense() { // Closure Compiler preserves dozens of Apache licences in the Blockly code. // Remove these if they belong to Google or MIT. // MIT's permission to do this is logged in Blockly issue #2412. - return gulp.replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n'); + return replace(new RegExp(licenseRegex, 'g'), '\n\n\n\n'); // Replace with the same number of lines so that source-maps are not affected. } @@ -306,7 +313,7 @@ const JSCOMP_OFF = [ * Builds Blockly as a JS program, by running tsc on all the files in * the core directory. */ -function buildJavaScript(done) { +export function tsc(done) { execSync( `tsc -outDir "${TSC_OUTPUT_DIR}" -declarationDir "${TYPINGS_BUILD_DIR}"`, {stdio: 'inherit'}); @@ -318,7 +325,7 @@ function buildJavaScript(done) { * This task regenerates msg/json/en.js and msg/json/qqq.js from * msg/messages.js. */ -function generateMessages(done) { +export function messages(done) { // Run js_to_json.py const jsToJsonCmd = `${PYTHON} scripts/i18n/js_to_json.py \ --input_file ${path.join('msg', 'messages.js')} \ @@ -573,10 +580,10 @@ function buildCompiled() { // Fire up compilation pipline. return gulp.src(chunkOptions.js, {base: './'}) .pipe(stripApacheLicense()) - .pipe(gulp.sourcemaps.init()) + .pipe(sourcemaps.init()) .pipe(compile(options)) - .pipe(gulp.rename({suffix: COMPILED_SUFFIX})) - .pipe(gulp.sourcemaps.write('.')) + .pipe(rename({suffix: COMPILED_SUFFIX})) + .pipe(sourcemaps.write('.')) .pipe(gulp.dest(RELEASE_DIR)); } @@ -668,7 +675,7 @@ async function buildLangfileShims() { // (We have to do it this way because messages.js is a script and // not a CJS module with exports.) globalThis.Blockly = {Msg: {}}; - require('../../msg/messages.js'); + await import('../../msg/messages.js'); const exportedNames = Object.keys(globalThis.Blockly.Msg); delete globalThis.Blockly; @@ -689,12 +696,14 @@ ${exportedNames.map((name) => ` ${name},`).join('\n')} } /** - * This task builds Blockly core, blocks and generators together and uses - * Closure Compiler's ADVANCED_COMPILATION mode. + * This task uses Closure Compiler's ADVANCED_COMPILATION mode to + * compile together Blockly core, blocks and generators with a simple + * test app; the purpose is to verify that Blockly is compatible with + * the ADVANCED_COMPILATION mode. * * Prerequisite: buildJavaScript. */ -function buildAdvancedCompilationTest() { +function compileAdvancedCompilationTest() { // If main_compressed.js exists (from a previous run) delete it so that // a later browser-based test won't check it should the compile fail. try { @@ -718,9 +727,9 @@ function buildAdvancedCompilationTest() { }; return gulp.src(srcs, {base: './'}) .pipe(stripApacheLicense()) - .pipe(gulp.sourcemaps.init()) + .pipe(sourcemaps.init()) .pipe(compile(options)) - .pipe(gulp.sourcemaps.write( + .pipe(sourcemaps.write( '.', {includeContent: false, sourceRoot: '../../'})) .pipe(gulp.dest('./tests/compile/')); } @@ -728,7 +737,7 @@ function buildAdvancedCompilationTest() { /** * This task cleans the build directory (by deleting it). */ -function cleanBuildDir() { +export function cleanBuildDir() { // Sanity check. if (BUILD_DIR === '.' || BUILD_DIR === '/') { return Promise.reject(`Refusing to rm -rf ${BUILD_DIR}`); @@ -737,16 +746,13 @@ function cleanBuildDir() { } // Main sequence targets. Each should invoke any immediate prerequisite(s). -exports.cleanBuildDir = cleanBuildDir; -exports.langfiles = gulp.parallel(buildLangfiles, buildLangfileShims); -exports.tsc = buildJavaScript; -exports.minify = gulp.series(exports.tsc, buildCompiled, buildShims); -exports.build = gulp.parallel(exports.minify, exports.langfiles); +// function cleanBuildDir, above +export const langfiles = gulp.parallel(buildLangfiles, buildLangfileShims); +export const minify = gulp.series(tsc, buildCompiled, buildShims); +// function tsc, above +export const build = gulp.parallel(minify, langfiles); // Manually-invokable targets, with prerequisites where required. -exports.messages = generateMessages; // Generate msg/json/en.json et al. -exports.buildAdvancedCompilationTest = - gulp.series(exports.tsc, buildAdvancedCompilationTest); - -// Targets intended only for invocation by scripts; may omit prerequisites. -exports.onlyBuildAdvancedCompilationTest = buildAdvancedCompilationTest; +// function messages, above +export const buildAdvancedCompilationTest = + gulp.series(tsc, compileAdvancedCompilationTest); diff --git a/scripts/gulpfiles/config.js b/scripts/gulpfiles/config.mjs similarity index 70% rename from scripts/gulpfiles/config.js rename to scripts/gulpfiles/config.mjs index 90cd57109..52e4cd06f 100644 --- a/scripts/gulpfiles/config.js +++ b/scripts/gulpfiles/config.mjs @@ -8,7 +8,7 @@ * @fileoverview Common configuration for Gulp scripts. */ -const path = require('path'); +import * as path from 'path'; // Paths are all relative to the repository root. Do not include // trailing slash. @@ -21,21 +21,21 @@ const path = require('path'); // - tests/scripts/update_metadata.sh // Directory to write compiled output to. -exports.BUILD_DIR = 'build'; +export const BUILD_DIR = 'build'; // Directory to write typings output to. -exports.TYPINGS_BUILD_DIR = path.join(exports.BUILD_DIR, 'declarations'); +export const TYPINGS_BUILD_DIR = path.join(BUILD_DIR, 'declarations'); // Directory to write langfile output to. -exports.LANG_BUILD_DIR = path.join(exports.BUILD_DIR, 'msg'); +export const LANG_BUILD_DIR = path.join(BUILD_DIR, 'msg'); // Directory where typescript compiler output can be found. // Matches the value in tsconfig.json: outDir -exports.TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'src'); +export const TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'src'); // Directory for files generated by compiling test code. -exports.TEST_TSC_OUTPUT_DIR = path.join(exports.BUILD_DIR, 'tests'); +export const TEST_TSC_OUTPUT_DIR = path.join(BUILD_DIR, 'tests'); // Directory in which to assemble (and from which to publish) the // blockly npm package. -exports.RELEASE_DIR = 'dist'; +export const RELEASE_DIR = 'dist'; diff --git a/scripts/gulpfiles/docs_tasks.js b/scripts/gulpfiles/docs_tasks.mjs similarity index 94% rename from scripts/gulpfiles/docs_tasks.js rename to scripts/gulpfiles/docs_tasks.mjs index 8820a586f..63fdbe665 100644 --- a/scripts/gulpfiles/docs_tasks.js +++ b/scripts/gulpfiles/docs_tasks.mjs @@ -1,9 +1,9 @@ -const {execSync} = require('child_process'); -const {Extractor} = require('markdown-tables-to-json'); -const fs = require('fs'); -const gulp = require('gulp'); -const header = require('gulp-header'); -const replace = require('gulp-replace'); +import {execSync} from 'child_process'; +import {Extractor} from 'markdown-tables-to-json'; +import * as fs from 'fs'; +import * as gulp from 'gulp'; +import * as header from 'gulp-header'; +import * as replace from 'gulp-replace'; const DOCS_DIR = 'docs'; @@ -140,8 +140,7 @@ const createToc = function(done) { done(); } -const docs = gulp.series( +export const docs = gulp.series( generateApiJson, removeRenames, generateDocs, gulp.parallel(prependBook, createToc)); -module.exports = {docs}; diff --git a/scripts/gulpfiles/git_tasks.js b/scripts/gulpfiles/git_tasks.mjs similarity index 86% rename from scripts/gulpfiles/git_tasks.js rename to scripts/gulpfiles/git_tasks.mjs index 7c320cd87..2b08e16b3 100644 --- a/scripts/gulpfiles/git_tasks.js +++ b/scripts/gulpfiles/git_tasks.mjs @@ -8,11 +8,11 @@ * @fileoverview Git-related gulp tasks for Blockly. */ -const gulp = require('gulp'); -const execSync = require('child_process').execSync; +import * as gulp from 'gulp'; +import {execSync} from 'child_process'; -const buildTasks = require('./build_tasks'); -const packageTasks = require('./package_tasks'); +import * as buildTasks from './build_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; const UPSTREAM_URL = 'https://github.com/google/blockly.git'; @@ -63,7 +63,7 @@ function syncBranch(branchName) { * Stash current state, check out develop, and sync with * google/blockly. */ -function syncDevelop() { +export function syncDevelop() { return syncBranch('develop'); }; @@ -71,7 +71,7 @@ function syncDevelop() { * Stash current state, check out master, and sync with * google/blockly. */ -function syncMaster() { +export function syncMaster() { return syncBranch('master'); }; @@ -111,7 +111,7 @@ function checkoutBranch(branchName) { * Create and push an RC branch. * Note that this pushes to google/blockly. */ -const createRC = gulp.series( +export const createRC = gulp.series( syncDevelop(), function(done) { const branchName = getRCBranchName(); @@ -122,7 +122,7 @@ const createRC = gulp.series( ); /** Create the rebuild branch. */ -function createRebuildBranch(done) { +export function createRebuildBranch(done) { const branchName = getRebuildBranchName(); console.log(`make-rebuild-branch: creating branch ${branchName}`); execSync(`git switch -C ${branchName}`, { stdio: 'inherit' }); @@ -130,7 +130,7 @@ function createRebuildBranch(done) { } /** Push the rebuild branch to origin. */ -function pushRebuildBranch(done) { +export function pushRebuildBranch(done) { console.log('push-rebuild-branch: committing rebuild'); execSync('git commit -am "Rebuild"', { stdio: 'inherit' }); const branchName = getRebuildBranchName(); @@ -145,7 +145,7 @@ function pushRebuildBranch(done) { * * Prerequisites (invoked): clean, build. */ -const updateGithubPages = gulp.series( +export const updateGithubPages = gulp.series( function(done) { execSync('git stash save -m "Stash for sync"', { stdio: 'inherit' }); execSync('git switch -C gh-pages', { stdio: 'inherit' }); @@ -165,17 +165,3 @@ const updateGithubPages = gulp.series( done(); } ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - updateGithubPages, - - // Manually-invokable targets that invoke prerequisites. - createRC, - - // Legacy script-only targets, to be deleted. - syncDevelop, - syncMaster, - createRebuildBranch, - pushRebuildBranch, -}; diff --git a/scripts/gulpfiles/helper_tasks.js b/scripts/gulpfiles/helper_tasks.js deleted file mode 100644 index b239d03f5..000000000 --- a/scripts/gulpfiles/helper_tasks.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Any gulp helper functions. - */ - -// Clears the require cache to ensure the package.json is up to date. -function getPackageJson() { - delete require.cache[require.resolve('../../package.json')] - return require('../../package.json'); -} - -module.exports = { - getPackageJson: getPackageJson -} diff --git a/scripts/gulpfiles/helper_tasks.mjs b/scripts/gulpfiles/helper_tasks.mjs new file mode 100644 index 000000000..2068de106 --- /dev/null +++ b/scripts/gulpfiles/helper_tasks.mjs @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Any gulp helper functions. + */ + +import Module from "node:module"; + +const require = Module.createRequire(import.meta.url); + +/** + * Load and return the contents of package.json. + * + * Uses require() rather than import, and clears the require cache, to + * ensure the loaded package.json data is up to date. + */ +export function getPackageJson() { + delete require.cache[require.resolve('../../package.json')]; + return require('../../package.json'); +} + diff --git a/scripts/gulpfiles/package_tasks.js b/scripts/gulpfiles/package_tasks.mjs similarity index 89% rename from scripts/gulpfiles/package_tasks.js rename to scripts/gulpfiles/package_tasks.mjs index 89264a0e3..948f855b0 100644 --- a/scripts/gulpfiles/package_tasks.js +++ b/scripts/gulpfiles/package_tasks.mjs @@ -8,20 +8,17 @@ * @fileoverview Gulp tasks to package Blockly for distribution on NPM. */ -const gulp = require('gulp'); -gulp.concat = require('gulp-concat'); -gulp.replace = require('gulp-replace'); -gulp.rename = require('gulp-rename'); -gulp.insert = require('gulp-insert'); -gulp.umd = require('gulp-umd'); -gulp.replace = require('gulp-replace'); +import * as gulp from 'gulp'; +import concat from 'gulp-concat'; +import replace from 'gulp-replace'; +import umd from 'gulp-umd'; -const path = require('path'); -const fs = require('fs'); -const {rimraf} = require('rimraf'); -const build = require('./build_tasks'); -const {getPackageJson} = require('./helper_tasks'); -const {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} = require('./config'); +import * as path from 'path'; +import * as fs from 'fs'; +import {rimraf} from 'rimraf'; +import * as build from './build_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import {BUILD_DIR, LANG_BUILD_DIR, RELEASE_DIR, TYPINGS_BUILD_DIR} from './config.mjs'; // Path to template files for gulp-umd. const TEMPLATE_DIR = 'scripts/package/templates'; @@ -32,7 +29,7 @@ const TEMPLATE_DIR = 'scripts/package/templates'; * @param {Array} dependencies An array of dependencies to inject. */ function packageUMD(namespace, dependencies, template = 'umd.template') { - return gulp.umd({ + return umd({ dependencies: function () { return dependencies; }, namespace: function () { return namespace; }, exports: function () { return namespace; }, @@ -88,7 +85,7 @@ function packageCoreNode() { function packageLocales() { // Remove references to goog.provide and goog.require. return gulp.src(`${LANG_BUILD_DIR}/*.js`) - .pipe(gulp.replace(/goog\.[^\n]+/g, '')) + .pipe(replace(/goog\.[^\n]+/g, '')) .pipe(packageUMD('Blockly.Msg', [], 'umd-msg.template')) .pipe(gulp.dest(`${RELEASE_DIR}/msg`)); }; @@ -107,7 +104,7 @@ function packageUMDBundle() { `${RELEASE_DIR}/javascript_compressed.js`, ]; return gulp.src(srcs) - .pipe(gulp.concat('blockly.min.js')) + .pipe(concat('blockly.min.js')) .pipe(gulp.dest(`${RELEASE_DIR}`)); }; @@ -140,7 +137,7 @@ function packageUMDBundle() { * @param {Function} done Callback to call when done. */ function packageLegacyEntrypoints(done) { - for (entrypoint of [ + for (const entrypoint of [ 'core', 'blocks', 'dart', 'javascript', 'lua', 'php', 'python' ]) { const bundle = @@ -218,14 +215,14 @@ function packageDTS() { .pipe(gulp.src(`${TYPINGS_BUILD_DIR}/**/*.d.ts`, {ignore: [ `${TYPINGS_BUILD_DIR}/blocks/**/*`, ]})) - .pipe(gulp.replace('AnyDuringMigration', 'any')) + .pipe(replace('AnyDuringMigration', 'any')) .pipe(gulp.dest(RELEASE_DIR)); }; /** * This task cleans the release directory (by deleting it). */ -function cleanReleaseDir() { +export function cleanReleaseDir() { // Sanity check. if (RELEASE_DIR === '.' || RELEASE_DIR === '/') { return Promise.reject(`Refusing to rm -rf ${RELEASE_DIR}`); @@ -237,9 +234,13 @@ function cleanReleaseDir() { * This task prepares the files to be included in the NPM by copying * them into the release directory. * + * This task was formerly called "package" but was renamed in + * preparation for porting gulpfiles to ESM because "package" is a + * reserved word. + * * Prerequisite: build. */ -const package = gulp.series( +export const pack = gulp.series( gulp.parallel( build.cleanBuildDir, cleanReleaseDir), @@ -254,9 +255,3 @@ const package = gulp.series( packageReadme, packageDTS) ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - cleanReleaseDir: cleanReleaseDir, - package: package, -}; diff --git a/scripts/gulpfiles/release_tasks.js b/scripts/gulpfiles/release_tasks.mjs similarity index 87% rename from scripts/gulpfiles/release_tasks.js rename to scripts/gulpfiles/release_tasks.mjs index f2545c7b9..a678a4f24 100644 --- a/scripts/gulpfiles/release_tasks.js +++ b/scripts/gulpfiles/release_tasks.mjs @@ -8,15 +8,15 @@ * @fileoverview Gulp scripts for releasing Blockly. */ -const execSync = require('child_process').execSync; -const fs = require('fs'); -const gulp = require('gulp'); -const readlineSync = require('readline-sync'); +import {execSync} from 'child_process'; +import * as fs from 'fs'; +import * as gulp from 'gulp'; +import * as readlineSync from 'readline-sync'; -const gitTasks = require('./git_tasks'); -const packageTasks = require('./package_tasks'); -const {getPackageJson} = require('./helper_tasks'); -const {RELEASE_DIR} = require('./config'); +import * as gitTasks from './git_tasks.mjs'; +import * as packageTasks from './package_tasks.mjs'; +import {getPackageJson} from './helper_tasks.mjs'; +import {RELEASE_DIR} from './config.mjs'; // Gets the current major version. @@ -147,17 +147,17 @@ function updateBetaVersion(done) { } // Rebuild, package and publish to npm. -const publish = gulp.series( - packageTasks.package, // Does clean + build. +export const publish = gulp.series( + packageTasks.pack, // Does clean + build. checkBranch, checkReleaseDir, loginAndPublish ); // Rebuild, package and publish a beta version of Blockly. -const publishBeta = gulp.series( +export const publishBeta = gulp.series( updateBetaVersion, - packageTasks.package, // Does clean + build. + packageTasks.pack, // Does clean + build. checkBranch, checkReleaseDir, loginAndPublishBeta @@ -165,19 +165,10 @@ const publishBeta = gulp.series( // Switch to a new branch, update the version number, build Blockly // and check in the resulting built files. -const recompileDevelop = gulp.series( +export const recompile = gulp.series( gitTasks.syncDevelop(), gitTasks.createRebuildBranch, updateVersionPrompt, - packageTasks.package, // Does clean + build. + packageTasks.pack, // Does clean + build. gitTasks.pushRebuildBranch ); - -module.exports = { - // Main sequence targets. Each should invoke any immediate prerequisite(s). - publishBeta, - publish, - - // Legacy target, to be deleted. - recompile: recompileDevelop, -}; diff --git a/scripts/gulpfiles/test_tasks.js b/scripts/gulpfiles/test_tasks.mjs similarity index 94% rename from scripts/gulpfiles/test_tasks.js rename to scripts/gulpfiles/test_tasks.mjs index 236a21d77..d4b73cdb3 100644 --- a/scripts/gulpfiles/test_tasks.js +++ b/scripts/gulpfiles/test_tasks.mjs @@ -9,19 +9,19 @@ */ /* eslint-env node */ -const asyncDone = require('async-done'); -const gulp = require('gulp'); -const gzip = require('gulp-gzip'); -const fs = require('fs'); -const path = require('path'); -const {execSync} = require('child_process'); -const {rimraf} = require('rimraf'); +import asyncDone from 'async-done'; +import * as gulp from 'gulp'; +import gzip from 'gulp-gzip'; +import * as fs from 'fs'; +import * as path from 'path'; +import {execSync} from 'child_process'; +import {rimraf} from 'rimraf'; -const {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} = require('./config'); +import {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} from './config.mjs'; -const {runMochaTestsInBrowser} = require('../../tests/mocha/webdriver.js'); -const {runGeneratorsInBrowser} = require('../../tests/generators/webdriver.js'); -const {runCompileCheckInBrowser} = require('../../tests/compile/webdriver.js'); +import {runMochaTestsInBrowser} from '../../tests/mocha/webdriver.js'; +import {runGeneratorsInBrowser} from '../../tests/generators/webdriver.js'; +import {runCompileCheckInBrowser} from '../../tests/compile/webdriver.js'; const OUTPUT_DIR = 'build/generators'; const GOLDEN_DIR = 'tests/generators/golden'; @@ -321,7 +321,7 @@ function checkResult(suffix) { * Run generator tests inside a browser and check the results. * @return {Promise} Asynchronous result. */ -async function generators() { +export async function generators() { return runTestTask('generators', async () => { // Clean up. rimraf.sync(OUTPUT_DIR); @@ -396,10 +396,6 @@ const tasks = [ advancedCompileInBrowser ]; -const test = gulp.series(...tasks, reportTestResult); +export const test = gulp.series(...tasks, reportTestResult); -module.exports = { - test, - generators, -}; From fa93ba2a2f98722a7a0e864b95dceb21dfdca059 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:42:25 +0100 Subject: [PATCH 04/17] chore(deps): bump glob from 11.0.2 to 11.0.3 (#9189) Bumps [glob](https://github.com/isaacs/node-glob) from 11.0.2 to 11.0.3. - [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md) - [Commits](https://github.com/isaacs/node-glob/compare/v11.0.2...v11.0.3) --- updated-dependencies: - dependency-name: glob dependency-version: 11.0.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 68 ++++++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8696a8418..78d4f61dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -813,6 +813,27 @@ "url": "https://github.com/sponsors/jdesrosiers" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4768,12 +4789,12 @@ } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -5018,15 +5039,14 @@ } }, "node_modules/glob": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.2.tgz", - "integrity": "sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "dev": true, - "license": "ISC", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -5097,24 +5117,13 @@ "node": ">= 10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/glob/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", + "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { "node": "20 || >=22" @@ -6279,11 +6288,10 @@ } }, "node_modules/jackspeak": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz", - "integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, From 460c8c8d1b99337cdb5345c768abb69207fec75e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:17:11 -0700 Subject: [PATCH 05/17] chore(deps): bump @blockly/block-test from 6.0.11 to 7.0.1 (#9192) --- updated-dependencies: - dependency-name: "@blockly/block-test" dependency-version: 7.0.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 23 +++++------------------ package.json | 2 +- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78d4f61dd..20706831b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "jsdom": "26.1.0" }, "devDependencies": { - "@blockly/block-test": "^6.0.4", + "@blockly/block-test": "^7.0.1", "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", @@ -89,15 +89,15 @@ "license": "ISC" }, "node_modules/@blockly/block-test": { - "version": "6.0.11", - "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-6.0.11.tgz", - "integrity": "sha512-aIgcxkof1gLJtJXKSvmnug9iSXbv5Qilnov4Sa/QNURiWJRxvMNqWiTZJVu/reuCQK4Qm4jadg9R9l+eu7ujvw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.1.tgz", + "integrity": "sha512-w91ZZbpJDKGQJVO7gKqQaM17ffcsW1ktrnSTz/OpDw5R4H+1q05NgWO5gYzGPzLfFdvPcrkc0v00KhD4UG7BRA==", "dev": true, "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/dev-tools": { @@ -126,19 +126,6 @@ "blockly": "^12.0.0" } }, - "node_modules/@blockly/dev-tools/node_modules/@blockly/block-test": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.1.tgz", - "integrity": "sha512-w91ZZbpJDKGQJVO7gKqQaM17ffcsW1ktrnSTz/OpDw5R4H+1q05NgWO5gYzGPzLfFdvPcrkc0v00KhD4UG7BRA==", - "dev": true, - "license": "Apache 2.0", - "engines": { - "node": ">=8.17.0" - }, - "peerDependencies": { - "blockly": "^12.0.0" - } - }, "node_modules/@blockly/dev-tools/node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", diff --git a/package.json b/package.json index 6ed5e4ea4..030eed6fd 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ }, "license": "Apache-2.0", "devDependencies": { - "@blockly/block-test": "^6.0.4", + "@blockly/block-test": "^7.0.1", "@blockly/dev-tools": "^9.0.0", "@blockly/theme-modern": "^6.0.3", "@hyperjump/browser": "^1.1.4", From fd3a7567640f3dd9122cb5f817b8c71e446c7890 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 1 Jul 2025 11:05:30 -0700 Subject: [PATCH 06/17] fix: Fix loss of focus when un/redoing block deletions or moves. (#9195) --- core/block_svg.ts | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/core/block_svg.ts b/core/block_svg.ts index a30cc34ed..49b4a1ee6 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -299,8 +299,19 @@ export class BlockSvg } const oldXY = this.getRelativeToSurfaceXY(); + const focusedNode = getFocusManager().getFocusedNode(); + const restoreFocus = this.getSvgRoot().contains( + focusedNode?.getFocusableElement() ?? null, + ); if (newParent) { (newParent as BlockSvg).getSvgRoot().appendChild(svgRoot); + // appendChild() clears focus state, so re-focus the previously focused + // node in case it was this block and would otherwise lose its focus. Once + // Element.moveBefore() has better browser support, it should be used + // instead. + if (restoreFocus && focusedNode) { + getFocusManager().focusNode(focusedNode); + } } else if (oldParent) { // If we are losing a parent, we want to move our DOM element to the // root of the workspace. Try to insert it before any top-level @@ -319,6 +330,13 @@ export class BlockSvg canvas.insertBefore(svgRoot, draggingBlockElement); } else { canvas.appendChild(svgRoot); + // appendChild() clears focus state, so re-focus the previously focused + // node in case it was this block and would otherwise lose its focus. Once + // Element.moveBefore() has better browser support, it should be used + // instead. + if (restoreFocus && focusedNode) { + getFocusManager().focusNode(focusedNode); + } } this.translate(oldXY.x, oldXY.y); } @@ -849,10 +867,30 @@ export class BlockSvg Tooltip.dispose(); ContextMenu.hide(); - // If this block was focused, focus its parent or workspace instead. + // If this block (or a descendant) was focused, focus its parent or + // workspace instead. const focusManager = getFocusManager(); - if (focusManager.getFocusedNode() === this) { - const parent = this.getParent(); + if ( + this.getSvgRoot().contains( + focusManager.getFocusedNode()?.getFocusableElement() ?? null, + ) + ) { + let parent: BlockSvg | undefined | null = this.getParent(); + if (!parent) { + // In some cases, blocks are disconnected from their parents before + // being deleted. Attempt to infer if there was a parent by checking + // for a connection within a radius of 0. Even if this wasn't a parent, + // it must be adjacent to this block and so is as good an option as any + // to focus after deleting. + const connection = this.outputConnection ?? this.previousConnection; + if (connection) { + const targetConnection = connection.closest( + 0, + new Coordinate(0, 0), + ).connection; + parent = targetConnection?.getSourceBlock(); + } + } if (parent) { focusManager.focusNode(parent); } else { From 0f73bd53d4ee9fd0f78e612160de492e3bc4e769 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:42:46 -0700 Subject: [PATCH 07/17] chore(deps): bump mocha from 11.7.0 to 11.7.1 (#9193) --- updated-dependencies: - dependency-name: mocha dependency-version: 11.7.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 20706831b..3b9a934a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6910,11 +6910,10 @@ } }, "node_modules/mocha": { - "version": "11.7.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.0.tgz", - "integrity": "sha512-bXfLy/mI8n4QICg+pWj1G8VduX5vC0SHRwFpiR5/Fxc8S2G906pSfkyMmHVsdJNQJQNh3LE67koad9GzEvkV6g==", + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.1.tgz", + "integrity": "sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==", "dev": true, - "license": "MIT", "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", From 19da66c5322c9cda52fce24bd922c5472077b4d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:46:13 -0700 Subject: [PATCH 08/17] chore(deps): bump gulp from 5.0.0 to 5.0.1 (#9188) Bumps [gulp](https://github.com/gulpjs/gulp) from 5.0.0 to 5.0.1. - [Release notes](https://github.com/gulpjs/gulp/releases) - [Changelog](https://github.com/gulpjs/gulp/blob/master/CHANGELOG.md) - [Commits](https://github.com/gulpjs/gulp/compare/v5.0.0...v5.0.1) --- updated-dependencies: - dependency-name: gulp dependency-version: 5.0.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 229 +++++++++++----------------------------------- 1 file changed, 53 insertions(+), 176 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b9a934a3..a778f6d83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2248,30 +2248,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/archiver-utils/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/archiver-utils/node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2394,30 +2370,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/archiver/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/archiver/node_modules/buffer-crc32": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", @@ -2800,30 +2752,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2858,6 +2786,30 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -3173,30 +3125,6 @@ "node": ">= 14" } }, - "node_modules/compress-commons/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/compress-commons/node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -3407,30 +3335,6 @@ "node": ">= 14" } }, - "node_modules/crc32-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/crc32-stream/node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -5061,9 +4965,9 @@ } }, "node_modules/glob-stream": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.2.tgz", - "integrity": "sha512-R8z6eTB55t3QeZMmU1C+Gv+t5UnNRkA55c5yo67fAVfxODxieTwsjNG7utxS/73NdP1NbDgCrhVEg2h00y4fFw==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.3.tgz", + "integrity": "sha512-fqZVj22LtFJkHODT+M4N1RJQ3TjnnQhfE9GwZI8qXscYarnhpip70poMldRnP8ipQ/w0B621kOhfc53/J9bd/A==", "dev": true, "dependencies": { "@gulpjs/to-absolute-glob": "^4.0.0", @@ -5292,15 +5196,15 @@ "license": "MIT" }, "node_modules/gulp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.0.tgz", - "integrity": "sha512-S8Z8066SSileaYw1S2N1I64IUc/myI2bqe2ihOBzO6+nKpvNSg7ZcWJt/AwF8LC/NVN+/QZ560Cb/5OPsyhkhg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/gulp/-/gulp-5.0.1.tgz", + "integrity": "sha512-PErok3DZSA5WGMd6XXV3IRNO0mlB+wW3OzhFJLEec1jSERg2j1bxJ6e5Fh6N6fn3FH2T9AP4UYNb/pYlADB9sA==", "dev": true, "dependencies": { "glob-watcher": "^6.0.0", - "gulp-cli": "^3.0.0", + "gulp-cli": "^3.1.0", "undertaker": "^2.0.0", - "vinyl-fs": "^4.0.0" + "vinyl-fs": "^4.0.2" }, "bin": { "gulp": "bin/gulp.js" @@ -5310,9 +5214,9 @@ } }, "node_modules/gulp-cli": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.0.0.tgz", - "integrity": "sha512-RtMIitkT8DEMZZygHK2vEuLPqLPAFB4sntSxg4NoDta7ciwGZ18l7JuhCTiS5deOJi2IoK0btE+hs6R4sfj7AA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/gulp-cli/-/gulp-cli-3.1.0.tgz", + "integrity": "sha512-zZzwlmEsTfXcxRKiCHsdyjZZnFvXWM4v1NqBJSYbuApkvVKivjcmOS2qruAJ+PkEHLFavcDKH40DPc1+t12a9Q==", "dev": true, "dependencies": { "@gulpjs/messages": "^1.1.0", @@ -5320,7 +5224,7 @@ "copy-props": "^4.0.0", "gulplog": "^2.2.0", "interpret": "^3.1.1", - "liftoff": "^5.0.0", + "liftoff": "^5.0.1", "mute-stdout": "^2.0.0", "replace-homedir": "^2.0.0", "semver-greatest-satisfied-range": "^2.0.0", @@ -6582,9 +6486,9 @@ } }, "node_modules/liftoff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.0.tgz", - "integrity": "sha512-a5BQjbCHnB+cy+gsro8lXJ4kZluzOijzJ1UVVfyJYZC+IP2pLv1h4+aysQeKuTmyO8NAqfyQAk4HWaP/HjcKTg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.1.tgz", + "integrity": "sha512-wwLXMbuxSF8gMvubFcFRp56lkFV69twvbU5vDPbaw+Q+/rF8j0HKjGbIdlSi+LuJm9jf7k9PB+nTxnsLMPcv2Q==", "dev": true, "dependencies": { "extend": "^3.0.2", @@ -9441,13 +9345,12 @@ } }, "node_modules/vinyl-contents/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -9457,13 +9360,13 @@ } }, "node_modules/vinyl-fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.0.tgz", - "integrity": "sha512-7GbgBnYfaquMk3Qu9g22x000vbYkOex32930rBnc3qByw6HfMEAoELjCjoJv4HuEQxHAurT+nvMHm6MnJllFLw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.2.tgz", + "integrity": "sha512-XRFwBLLTl8lRAOYiBqxY279wY46tVxLaRhSwo3GzKEuLz1giffsOquWWboD/haGf5lx+JyTigCFfe7DWHoARIA==", "dev": true, "dependencies": { "fs-mkdirp-stream": "^2.0.1", - "glob-stream": "^8.0.0", + "glob-stream": "^8.0.3", "graceful-fs": "^4.2.11", "iconv-lite": "^0.6.3", "is-valid-glob": "^1.0.0", @@ -9474,7 +9377,7 @@ "streamx": "^2.14.0", "to-through": "^3.0.0", "value-or-function": "^4.0.0", - "vinyl": "^3.0.0", + "vinyl": "^3.0.1", "vinyl-sourcemap": "^2.0.0" }, "engines": { @@ -9482,13 +9385,12 @@ } }, "node_modules/vinyl-fs/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -9521,13 +9423,12 @@ "dev": true }, "node_modules/vinyl-sourcemap/node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -9969,30 +9870,6 @@ "node": ">= 14" } }, - "node_modules/zip-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/zip-stream/node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", From c426c6d820495d2b34dec7ac6a2fe62cfb02506c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 1 Jul 2025 14:07:39 -0700 Subject: [PATCH 09/17] fix: Short-circuit node lookups for missing IDs (#9174) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes #9155 ### Proposed Changes In cases when an ID is missing for an element passed to `FocusableTreeTraverser.findFocusableNodeFor()`, always return `null`. Additionally, the new short-circuit logic exposed that `Toolbox` actually wasn't being set up correctly (that is, its root element was not being configured with a valid ID). This has been fixed. ### Reason for Changes These are cases when a valid node should never be matched (and it's technically possible to incorrectly match if an `IFocusableNode` is set up incorrectly and is providing a focusable element with an unset ID). This avoids the extra computation time of potentially calling deep into `WorkspaceSvg` and exploring all possible nodes for an ID that should never match. Note that there is a weird quirk with `null` IDs actually being the string `"null"`. This is a side effect of how `setAttribute` and attributes in general work with HTML elements. There's nothing really that can be done here, so it's now considered invalid to also have an ID of string `"null"` just to ensure the `null` case is properly short-circuited. Finally, the issue with toolbox being configured incorrectly was discovered with the introducing of a new hard failure in `FocusManager.registerTree()` when a tree with an invalid root element is registered. From testing there are no other such trees that need to be updated. A new warning was also added if `focusNode()` is used on a node with an element that has an invalid ID. This isn't a hard failure to follow the convention of other invalid `focusNode()` situations. It's much more fragile for `focusNode()` to throw than `registerTree()` since the former generally happens much earlier in a page lifecycle, and is less prone to dynamic behaviors. ### Test Coverage New tests were added to validate the various empty ID cases for `FocusableTreeTraverser.findFocusableNodeFor()`, and to validate the new error check for `FocusManager.registerTree()`. ### Documentation No new documentation should be needed. ### Additional Information Nothing to add. --- core/focus_manager.ts | 20 +++++- core/toolbox/toolbox.ts | 2 + core/utils/focusable_tree_traverser.ts | 8 ++- tests/mocha/focus_manager_test.js | 48 +++++++++++++ tests/mocha/focusable_tree_traverser_test.js | 74 ++++++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 3d0a9347f..02e059107 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -174,8 +174,15 @@ export class FocusManager { this.registeredTrees.push( new TreeRegistration(tree, rootShouldBeAutoTabbable), ); + const rootElement = tree.getRootFocusableNode().getFocusableElement(); + if (!rootElement.id || rootElement.id === 'null') { + throw Error( + `Attempting to register a tree with a root element that has an ` + + `invalid ID: ${tree}.`, + ); + } if (rootShouldBeAutoTabbable) { - tree.getRootFocusableNode().getFocusableElement().tabIndex = 0; + rootElement.tabIndex = 0; } } @@ -344,13 +351,22 @@ export class FocusManager { throw Error(`Attempted to focus unregistered node: ${focusableNode}.`); } + const focusableNodeElement = focusableNode.getFocusableElement(); + if (!focusableNodeElement.id || focusableNodeElement.id === 'null') { + // Warn that the ID is invalid, but continue execution since an invalid ID + // will result in an unmatched (null) node. Since a request to focus + // something was initiated, the code below will attempt to find the next + // best thing to focus, instead. + console.warn('Trying to focus a node that has an invalid ID.'); + } + // Safety check for ensuring focusNode() doesn't get called for a node that // isn't actually hooked up to its parent tree correctly. This usually // happens when calls to focusNode() interleave with asynchronous clean-up // operations (which can happen due to ephemeral focus and in other cases). // Fall back to a reasonable default since there's no valid node to focus. const matchedNode = FocusableTreeTraverser.findFocusableNodeFor( - focusableNode.getFocusableElement(), + focusableNodeElement, nextTree, ); const prevNodeNextTree = FocusableTreeTraverser.findFocusedNode(nextTree); diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 57e849ce2..31bb2b636 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -43,6 +43,7 @@ import type {KeyboardShortcut} from '../shortcut_registry.js'; import * as Touch from '../touch.js'; import * as aria from '../utils/aria.js'; import * as dom from '../utils/dom.js'; +import * as idGenerator from '../utils/idgenerator.js'; import {Rect} from '../utils/rect.js'; import * as toolbox from '../utils/toolbox.js'; import type {WorkspaceSvg} from '../workspace_svg.js'; @@ -185,6 +186,7 @@ export class Toolbox const svg = workspace.getParentSvg(); const container = this.createContainer_(); + container.id = idGenerator.getNextUniqueId(); this.contentsDiv_ = this.createContentsContainer_(); aria.setRole(this.contentsDiv_, aria.Role.TREE); diff --git a/core/utils/focusable_tree_traverser.ts b/core/utils/focusable_tree_traverser.ts index 916437b6a..aa4585b82 100644 --- a/core/utils/focusable_tree_traverser.ts +++ b/core/utils/focusable_tree_traverser.ts @@ -79,8 +79,8 @@ export class FocusableTreeTraverser { * traversed but its nodes will never be returned here per the contract of * IFocusableTree.lookUpFocusableNode. * - * The provided element must have a non-null ID that conforms to the contract - * mentioned in IFocusableNode. + * The provided element must have a non-null, non-empty ID that conforms to + * the contract mentioned in IFocusableNode. * * @param element The HTML or SVG element being sought. * @param tree The tree under which the provided element may be a descendant. @@ -90,6 +90,10 @@ export class FocusableTreeTraverser { element: HTMLElement | SVGElement, tree: IFocusableTree, ): IFocusableNode | null { + // Note that the null check is due to Element.setAttribute() converting null + // to a string. + if (!element.id || element.id === 'null') return null; + // First, match against subtrees. const subTreeMatches = tree.getNestedTrees().map((tree) => { return FocusableTreeTraverser.findFocusableNodeFor(element, tree); diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index 3a1fc98a7..26dcb8dbe 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -249,6 +249,54 @@ suite('FocusManager', function () { // The second register should not fail since the tree was previously unregistered. }); + test('for tree with missing ID throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.removeAttribute('id'); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + + test('for tree with null ID throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.setAttribute('id', null); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + + test('for tree with empty throws error', function () { + const rootNode = this.testFocusableTree1.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + rootElem.setAttribute('id', ''); + + const errorMsgRegex = + /Attempting to register a tree with a root element that has an invalid ID.+?/; + assert.throws( + () => this.focusManager.registerTree(this.testFocusableTree1), + errorMsgRegex, + ); + // Restore the ID for other tests. + rootElem.id = oldId; + }); + test('for unmanaged tree does not overwrite tab index', function () { this.focusManager.registerTree(this.testFocusableTree1, false); diff --git a/tests/mocha/focusable_tree_traverser_test.js b/tests/mocha/focusable_tree_traverser_test.js index 66cc598cc..0f88e1106 100644 --- a/tests/mocha/focusable_tree_traverser_test.js +++ b/tests/mocha/focusable_tree_traverser_test.js @@ -348,6 +348,80 @@ suite('FocusableTreeTraverser', function () { }); suite('findFocusableNodeFor()', function () { + test('for element without ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // Normally it's not valid to miss an ID, but it can realistically happen. + rootElem.removeAttribute('id'); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with null ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // Normally it's not valid to miss an ID, but it can realistically happen. + rootElem.setAttribute('id', null); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with null ID string returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // This is a quirky version of the null variety above that's actually + // functionallity equivalent (since 'null' is converted to a string). + rootElem.setAttribute('id', 'null'); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + + test('for element with empty ID returns null', function () { + const tree = this.testFocusableTree1; + const rootNode = tree.getRootFocusableNode(); + const rootElem = rootNode.getFocusableElement(); + const oldId = rootElem.id; + // An empty ID is invalid since it will potentially conflict with other + // elements, and element IDs must be unique for focus management. + rootElem.setAttribute('id', ''); + + const finding = FocusableTreeTraverser.findFocusableNodeFor( + rootElem, + tree, + ); + // Restore the ID for other tests. + rootElem.setAttribute('id', oldId); + + assert.isNull(finding); + }); + test('for root element returns root', function () { const tree = this.testFocusableTree1; const rootNode = tree.getRootFocusableNode(); From e5804e709563f6e939a2a7957c3150403414c7b1 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Tue, 1 Jul 2025 15:13:13 -0700 Subject: [PATCH 10/17] feat: Add support for keyboard navigation in/to workspace comments. (#9182) * feat: Enhance the Rect API. * feat: Add support for sorting IBoundedElements in general. * fix: Improve typings of getTopElement/Comment methods. * feat: Add classes to represent comment icons. * refactor: Use comment icons in comment view. * feat: Update navigation policies to support workspace comments. * feat: Make the navigator and workspace handle workspace comments. * feat: Visit workspace comments when navigating with the up/down arrows. * chore: Make the linter happy. * chore: Rename comment icons to bar buttons. * refactor: Rename CommentIcons to CommentBarButtons. * chore: Improve docstrings. * chore: Clarify unit type. * refactor: Remove workspace argument from `navigateStacks()`. * fix: Fix errant find and replace in CSS. * fix: Fix issue that could cause delete button to become misaligned. --- core/comments.ts | 3 + core/comments/collapse_comment_bar_button.ts | 101 ++++++++++ core/comments/comment_bar_button.ts | 105 ++++++++++ core/comments/comment_view.ts | 190 +++++------------- core/comments/delete_comment_bar_button.ts | 102 ++++++++++ core/keyboard_nav/block_navigation_policy.ts | 29 ++- .../comment_bar_button_navigation_policy.ts | 86 ++++++++ core/keyboard_nav/line_cursor.ts | 27 +-- .../workspace_comment_navigation_policy.ts | 77 +++++++ core/navigator.ts | 4 + core/utils/rect.ts | 15 ++ core/workspace.ts | 27 ++- core/workspace_svg.ts | 68 +++++-- 13 files changed, 652 insertions(+), 182 deletions(-) create mode 100644 core/comments/collapse_comment_bar_button.ts create mode 100644 core/comments/comment_bar_button.ts create mode 100644 core/comments/delete_comment_bar_button.ts create mode 100644 core/keyboard_nav/comment_bar_button_navigation_policy.ts create mode 100644 core/keyboard_nav/workspace_comment_navigation_policy.ts diff --git a/core/comments.ts b/core/comments.ts index 86e8f50b9..179ab4a33 100644 --- a/core/comments.ts +++ b/core/comments.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js'; +export {CommentBarButton} from './comments/comment_bar_button.js'; export {CommentEditor} from './comments/comment_editor.js'; export {CommentView} from './comments/comment_view.js'; +export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js'; export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; export {WorkspaceComment} from './comments/workspace_comment.js'; diff --git a/core/comments/collapse_comment_bar_button.ts b/core/comments/collapse_comment_bar_button.ts new file mode 100644 index 000000000..b0738d707 --- /dev/null +++ b/core/comments/collapse_comment_bar_button.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentBarButton} from './comment_bar_button.js'; + +/** + * Magic string appended to the comment ID to create a unique ID for this button. + */ +export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER = + '_collapse_bar_button'; + +/** + * Button that toggles the collapsed state of a comment. + */ +export class CollapseCommentBarButton extends CommentBarButton { + /** + * Opaque ID used to unbind event handlers during disposal. + */ + private readonly bindId: browserEvents.Data; + + /** + * SVG image displayed on this button. + */ + protected override readonly icon: SVGImageElement; + + /** + * Creates a new CollapseCommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is displayed on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) { + super(id, workspace, container); + + this.icon = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyFoldoutIcon', + 'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`, + 'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`, + }, + this.container, + ); + this.bindId = browserEvents.conditionalBind( + this.icon, + 'pointerdown', + this, + this.performAction.bind(this), + ); + } + + /** + * Disposes of this button. + */ + dispose() { + browserEvents.unbind(this.bindId); + } + + /** + * Adjusts the positioning of this button within its container. + */ + override reposition() { + const margin = this.getMargin(); + this.icon.setAttribute('y', `${margin}`); + this.icon.setAttribute('x', `${margin}`); + } + + /** + * Toggles the collapsed state of the parent comment. + * + * @param e The event that triggered this action. + */ + override performAction(e?: Event) { + touch.clearTouchIdentifier(); + + const comment = this.getParentComment(); + comment.view.bringToFront(); + if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + comment.setCollapsed(!comment.isCollapsed()); + this.workspace.hideChaff(); + + e?.stopPropagation(); + } +} diff --git a/core/comments/comment_bar_button.ts b/core/comments/comment_bar_button.ts new file mode 100644 index 000000000..d78a7fd86 --- /dev/null +++ b/core/comments/comment_bar_button.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {Rect} from '../utils/rect.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import type {RenderedWorkspaceComment} from './rendered_workspace_comment.js'; + +/** + * Button displayed on a comment's top bar. + */ +export abstract class CommentBarButton implements IFocusableNode { + /** + * SVG image displayed on this button. + */ + protected abstract readonly icon: SVGImageElement; + + /** + * Creates a new CommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) {} + + /** + * Returns whether or not this button is currently visible. + */ + isVisible(): boolean { + return this.icon.checkVisibility(); + } + + /** + * Returns the parent comment of this comment bar button. + */ + getParentComment(): RenderedWorkspaceComment { + const comment = this.workspace.getCommentById(this.id); + if (!comment) { + throw new Error( + `Comment bar button ${this.id} has no corresponding comment`, + ); + } + + return comment; + } + + /** Adjusts the position of this button within its parent container. */ + abstract reposition(): void; + + /** Perform the action this button should take when it is acted on. */ + abstract performAction(e?: Event): void; + + /** + * Returns the dimensions of this button in workspace coordinates. + * + * @param includeMargin True to include the margin when calculating the size. + * @returns The size of this button. + */ + getSize(includeMargin = false): Rect { + const bounds = this.icon.getBBox(); + const rect = Rect.from(bounds); + if (includeMargin) { + const margin = this.getMargin(); + rect.left -= margin; + rect.top -= margin; + rect.bottom += margin; + rect.right += margin; + } + return rect; + } + + /** Returns the margin in workspace coordinates surrounding this button. */ + getMargin(): number { + return (this.container.getBBox().height - this.icon.getBBox().height) / 2; + } + + /** Returns a DOM element representing this button that can receive focus. */ + getFocusableElement() { + return this.icon; + } + + /** Returns the workspace this button is a child of. */ + getFocusableTree() { + return this.workspace; + } + + /** Called when this button's focusable DOM element gains focus. */ + onNodeFocus() {} + + /** Called when this button's focusable DOM element loses focus. */ + onNodeBlur() {} + + /** Returns whether this button can be focused. True if it is visible. */ + canBeFocused() { + return this.isVisible(); + } +} diff --git a/core/comments/comment_view.ts b/core/comments/comment_view.ts index 1e5ad4a52..936d74650 100644 --- a/core/comments/comment_view.ts +++ b/core/comments/comment_view.ts @@ -16,14 +16,17 @@ import * as drag from '../utils/drag.js'; import {Size} from '../utils/size.js'; import {Svg} from '../utils/svg.js'; import {WorkspaceSvg} from '../workspace_svg.js'; +import {CollapseCommentBarButton} from './collapse_comment_bar_button.js'; +import {CommentBarButton} from './comment_bar_button.js'; import {CommentEditor} from './comment_editor.js'; +import {DeleteCommentBarButton} from './delete_comment_bar_button.js'; export class CommentView implements IRenderedElement { /** The root group element of the comment view. */ private svgRoot: SVGGElement; /** - * The svg rect element that we use to create a hightlight around the comment. + * The SVG rect element that we use to create a highlight around the comment. */ private highlightRect: SVGRectElement; @@ -33,11 +36,11 @@ export class CommentView implements IRenderedElement { /** The rect background for the top bar. */ private topBarBackground: SVGRectElement; - /** The delete icon that goes in the top bar. */ - private deleteIcon: SVGImageElement; + /** The delete button that goes in the top bar. */ + private deleteButton: DeleteCommentBarButton; - /** The foldout icon that goes in the top bar. */ - private foldoutIcon: SVGImageElement; + /** The foldout button that goes in the top bar. */ + private foldoutButton: CollapseCommentBarButton; /** The text element that goes in the top bar. */ private textPreview: SVGTextElement; @@ -99,7 +102,7 @@ export class CommentView implements IRenderedElement { constructor( readonly workspace: WorkspaceSvg, - private commentId?: string, + private commentId: string, ) { this.svgRoot = dom.createSvgElement(Svg.G, { 'class': 'blocklyComment blocklyEditable blocklyDraggable', @@ -110,11 +113,11 @@ export class CommentView implements IRenderedElement { ({ topBarGroup: this.topBarGroup, topBarBackground: this.topBarBackground, - deleteIcon: this.deleteIcon, - foldoutIcon: this.foldoutIcon, + deleteButton: this.deleteButton, + foldoutButton: this.foldoutButton, textPreview: this.textPreview, textPreviewNode: this.textPreviewNode, - } = this.createTopBar(this.svgRoot, workspace)); + } = this.createTopBar(this.svgRoot)); this.commentEditor = this.createTextArea(); @@ -147,14 +150,11 @@ export class CommentView implements IRenderedElement { * Creates the top bar and the elements visually within it. * Registers event listeners. */ - private createTopBar( - svgRoot: SVGGElement, - workspace: WorkspaceSvg, - ): { + private createTopBar(svgRoot: SVGGElement): { topBarGroup: SVGGElement; topBarBackground: SVGRectElement; - deleteIcon: SVGImageElement; - foldoutIcon: SVGImageElement; + deleteButton: DeleteCommentBarButton; + foldoutButton: CollapseCommentBarButton; textPreview: SVGTextElement; textPreviewNode: Text; } { @@ -172,22 +172,14 @@ export class CommentView implements IRenderedElement { }, topBarGroup, ); - // TODO: Before merging, does this mean to override an individual image, - // folks need to replace the whole media folder? - const deleteIcon = dom.createSvgElement( - Svg.IMAGE, - { - 'class': 'blocklyDeleteIcon', - 'href': `${workspace.options.pathToMedia}delete-icon.svg`, - }, + const deleteButton = new DeleteCommentBarButton( + this.commentId, + this.workspace, topBarGroup, ); - const foldoutIcon = dom.createSvgElement( - Svg.IMAGE, - { - 'class': 'blocklyFoldoutIcon', - 'href': `${workspace.options.pathToMedia}foldout-icon.svg`, - }, + const foldoutButton = new CollapseCommentBarButton( + this.commentId, + this.workspace, topBarGroup, ); const textPreview = dom.createSvgElement( @@ -200,27 +192,11 @@ export class CommentView implements IRenderedElement { const textPreviewNode = document.createTextNode(''); textPreview.appendChild(textPreviewNode); - // TODO(toychest): Triggering this on pointerdown means that we can't start - // drags on the foldout icon. We need to open up the gesture system - // to fix this. - browserEvents.conditionalBind( - foldoutIcon, - 'pointerdown', - this, - this.onFoldoutDown, - ); - browserEvents.conditionalBind( - deleteIcon, - 'pointerdown', - this, - this.onDeleteDown, - ); - return { topBarGroup, topBarBackground, - deleteIcon, - foldoutIcon, + deleteButton, + foldoutButton, textPreview, textPreviewNode, }; @@ -300,15 +276,10 @@ export class CommentView implements IRenderedElement { */ setSizeWithoutFiringEvents(size: Size) { const topBarSize = this.topBarBackground.getBBox(); - const deleteSize = this.deleteIcon.getBBox(); - const foldoutSize = this.foldoutIcon.getBBox(); const textPreviewSize = this.textPreview.getBBox(); const resizeSize = this.resizeHandle.getBBox(); - size = Size.max( - size, - this.calcMinSize(topBarSize, foldoutSize, deleteSize), - ); + size = Size.max(size, this.calcMinSize(topBarSize)); this.size = size; this.svgRoot.setAttribute('height', `${size.height}`); @@ -317,15 +288,9 @@ export class CommentView implements IRenderedElement { this.updateHighlightRect(size); this.updateTopBarSize(size); this.commentEditor.updateSize(size, topBarSize); - this.updateDeleteIconPosition(size, topBarSize, deleteSize); - this.updateFoldoutIconPosition(topBarSize, foldoutSize); - this.updateTextPreviewSize( - size, - topBarSize, - textPreviewSize, - deleteSize, - resizeSize, - ); + this.deleteButton.reposition(); + this.foldoutButton.reposition(); + this.updateTextPreviewSize(size, topBarSize, textPreviewSize); this.updateResizeHandlePosition(size, resizeSize); } @@ -347,25 +312,18 @@ export class CommentView implements IRenderedElement { * * The minimum height is based on the height of the top bar. */ - private calcMinSize( - topBarSize: Size, - foldoutSize: Size, - deleteSize: Size, - ): Size { + private calcMinSize(topBarSize: Size): Size { this.updateTextPreview(this.commentEditor.getText() ?? ''); const textPreviewWidth = dom.getTextWidth(this.textPreview); - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - let width = textPreviewWidth; - if (this.foldoutIcon.checkVisibility()) { - width += foldoutSize.width + foldoutMargin * 2; + if (this.foldoutButton.isVisible()) { + width += this.foldoutButton.getSize(true).getWidth(); } else if (textPreviewWidth) { width += 4; // Arbitrary margin before text. } - if (this.deleteIcon.checkVisibility()) { - width += deleteSize.width + deleteMargin * 2; + if (this.deleteButton.isVisible()) { + width += this.deleteButton.getSize(true).getWidth(); } else if (textPreviewWidth) { width += 4; // Arbitrary margin after text. } @@ -376,16 +334,6 @@ export class CommentView implements IRenderedElement { return new Size(width, height); } - /** Calculates the margin that should exist around the delete icon. */ - private calcDeleteMargin(topBarSize: Size, deleteSize: Size) { - return (topBarSize.height - deleteSize.height) / 2; - } - - /** Calculates the margin that should exist around the foldout icon. */ - private calcFoldoutMargin(topBarSize: Size, foldoutSize: Size) { - return (topBarSize.height - foldoutSize.height) / 2; - } - /** Updates the size of the highlight rect to reflect the new size. */ private updateHighlightRect(size: Size) { this.highlightRect.setAttribute('height', `${size.height}`); @@ -400,31 +348,6 @@ export class CommentView implements IRenderedElement { this.topBarBackground.setAttribute('width', `${size.width}`); } - /** - * Updates the position of the delete icon elements to reflect the new size. - */ - private updateDeleteIconPosition( - size: Size, - topBarSize: Size, - deleteSize: Size, - ) { - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - this.deleteIcon.setAttribute('y', `${deleteMargin}`); - this.deleteIcon.setAttribute( - 'x', - `${size.width - deleteSize.width - deleteMargin}`, - ); - } - - /** - * Updates the position of the foldout icon elements to reflect the new size. - */ - private updateFoldoutIconPosition(topBarSize: Size, foldoutSize: Size) { - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); - this.foldoutIcon.setAttribute('y', `${foldoutMargin}`); - this.foldoutIcon.setAttribute('x', `${foldoutMargin}`); - } - /** * Updates the size and position of the text preview elements to reflect the new size. */ @@ -432,25 +355,14 @@ export class CommentView implements IRenderedElement { size: Size, topBarSize: Size, textPreviewSize: Size, - deleteSize: Size, - foldoutSize: Size, ) { const textPreviewMargin = (topBarSize.height - textPreviewSize.height) / 2; - const deleteMargin = this.calcDeleteMargin(topBarSize, deleteSize); - const foldoutMargin = this.calcFoldoutMargin(topBarSize, foldoutSize); + const foldoutSize = this.foldoutButton.getSize(true); + const deleteSize = this.deleteButton.getSize(true); const textPreviewWidth = - size.width - - foldoutSize.width - - foldoutMargin * 2 - - deleteSize.width - - deleteMargin * 2; - this.textPreview.setAttribute( - 'x', - `${ - foldoutSize.width + foldoutMargin * 2 * (this.workspace.RTL ? -1 : 1) - }`, - ); + size.width - foldoutSize.getWidth() - deleteSize.getWidth(); + this.textPreview.setAttribute('x', `${foldoutSize.getWidth()}`); this.textPreview.setAttribute( 'y', `${textPreviewMargin + textPreviewSize.height / 2}`, @@ -601,25 +513,6 @@ export class CommentView implements IRenderedElement { ); } - /** - * Toggles the collapsedness of the block when we receive a pointer down - * event on the foldout icon. - */ - private onFoldoutDown(e: PointerEvent) { - touch.clearTouchIdentifier(); - this.bringToFront(); - if (browserEvents.isRightButton(e)) { - e.stopPropagation(); - return; - } - - this.setCollapsed(!this.collapsed); - - this.workspace.hideChaff(); - - e.stopPropagation(); - } - /** Returns true if the comment is currently editable. */ isEditable(): boolean { return this.editable; @@ -692,7 +585,7 @@ export class CommentView implements IRenderedElement { } /** Brings the workspace comment to the front of its layer. */ - private bringToFront() { + bringToFront() { const parent = this.svgRoot.parentNode; const childNodes = parent!.childNodes; // Avoid moving the comment if it's already at the bottom. @@ -719,6 +612,8 @@ export class CommentView implements IRenderedElement { /** Disposes of this comment view. */ dispose() { this.disposing = true; + this.foldoutButton.dispose(); + this.deleteButton.dispose(); dom.removeNode(this.svgRoot); // Loop through listeners backwards in case they remove themselves. for (let i = this.disposeListeners.length - 1; i >= 0; i--) { @@ -749,6 +644,13 @@ export class CommentView implements IRenderedElement { removeDisposeListener(listener: () => void) { this.disposeListeners.splice(this.disposeListeners.indexOf(listener), 1); } + + /** + * @internal + */ + getCommentBarButtons(): CommentBarButton[] { + return [this.foldoutButton, this.deleteButton]; + } } css.register(` diff --git a/core/comments/delete_comment_bar_button.ts b/core/comments/delete_comment_bar_button.ts new file mode 100644 index 000000000..ccdd02539 --- /dev/null +++ b/core/comments/delete_comment_bar_button.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as browserEvents from '../browser_events.js'; +import * as touch from '../touch.js'; +import * as dom from '../utils/dom.js'; +import {Svg} from '../utils/svg.js'; +import type {WorkspaceSvg} from '../workspace_svg.js'; +import {CommentBarButton} from './comment_bar_button.js'; + +/** + * Magic string appended to the comment ID to create a unique ID for this button. + */ +export const COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER = '_delete_bar_button'; + +/** + * Button that deletes a comment. + */ +export class DeleteCommentBarButton extends CommentBarButton { + /** + * Opaque ID used to unbind event handlers during disposal. + */ + private readonly bindId: browserEvents.Data; + + /** + * SVG image displayed on this button. + */ + protected override readonly icon: SVGImageElement; + + /** + * Creates a new DeleteCommentBarButton instance. + * + * @param id The ID of this button's parent comment. + * @param workspace The workspace this button's parent comment is shown on. + * @param container An SVG group that this button should be a child of. + */ + constructor( + protected readonly id: string, + protected readonly workspace: WorkspaceSvg, + protected readonly container: SVGGElement, + ) { + super(id, workspace, container); + + this.icon = dom.createSvgElement( + Svg.IMAGE, + { + 'class': 'blocklyDeleteIcon', + 'href': `${this.workspace.options.pathToMedia}delete-icon.svg`, + 'id': `${this.id}${COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER}`, + }, + container, + ); + this.bindId = browserEvents.conditionalBind( + this.icon, + 'pointerdown', + this, + this.performAction.bind(this), + ); + } + + /** + * Disposes of this button. + */ + dispose() { + browserEvents.unbind(this.bindId); + } + + /** + * Adjusts the positioning of this button within its container. + */ + override reposition() { + const margin = this.getMargin(); + // Reset to 0 so that our position doesn't force the parent container to + // grow. + this.icon.setAttribute('x', `0`); + const containerSize = this.container.getBBox(); + this.icon.setAttribute('y', `${margin}`); + this.icon.setAttribute( + 'x', + `${containerSize.width - this.getSize(true).getWidth()}`, + ); + } + + /** + * Deletes parent comment. + * + * @param e The event that triggered this action. + */ + override performAction(e?: Event) { + touch.clearTouchIdentifier(); + if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) { + e.stopPropagation(); + return; + } + + this.getParentComment().dispose(); + e?.stopPropagation(); + } +} diff --git a/core/keyboard_nav/block_navigation_policy.ts b/core/keyboard_nav/block_navigation_policy.ts index 2637ad49d..9f56b5384 100644 --- a/core/keyboard_nav/block_navigation_policy.ts +++ b/core/keyboard_nav/block_navigation_policy.ts @@ -8,8 +8,11 @@ import {BlockSvg} from '../block_svg.js'; import {ConnectionType} from '../connection_type.js'; import type {Field} from '../field.js'; import type {Icon} from '../icons/icon.js'; +import type {IBoundedElement} from '../interfaces/i_bounded_element.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import {isFocusableNode} from '../interfaces/i_focusable_node.js'; import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import type {ISelectable} from '../interfaces/i_selectable.js'; import {RenderedConnection} from '../rendered_connection.js'; import {WorkspaceSvg} from '../workspace_svg.js'; @@ -143,21 +146,25 @@ function getBlockNavigationCandidates( } /** - * Returns the next/previous stack relative to the given block's stack. + * Returns the next/previous stack relative to the given element's stack. * - * @param current The block whose stack will be navigated relative to. + * @param current The element whose stack will be navigated relative to. * @param delta The difference in index to navigate; positive values navigate * to the nth next stack, while negative values navigate to the nth previous * stack. - * @returns The first block in the stack offset by `delta` relative to the - * current block's stack, or the last block in the stack offset by `delta` - * relative to the current block's stack when navigating backwards. + * @returns The first element in the stack offset by `delta` relative to the + * current element's stack, or the last element in the stack offset by + * `delta` relative to the current element's stack when navigating backwards. */ -export function navigateStacks(current: BlockSvg, delta: number) { - const stacks = current.workspace.getTopBlocks(true); - const currentIndex = stacks.indexOf(current.getRootBlock()); +export function navigateStacks(current: ISelectable, delta: number) { + const stacks: IFocusableNode[] = (current.workspace as WorkspaceSvg) + .getTopBoundedElements(true) + .filter((element: IBoundedElement) => isFocusableNode(element)); + const currentIndex = stacks.indexOf( + current instanceof BlockSvg ? current.getRootBlock() : current, + ); const targetIndex = currentIndex + delta; - let result: BlockSvg | null = null; + let result: IFocusableNode | null = null; if (targetIndex >= 0 && targetIndex < stacks.length) { result = stacks[targetIndex]; } else if (targetIndex < 0) { @@ -166,9 +173,9 @@ export function navigateStacks(current: BlockSvg, delta: number) { result = stacks[0]; } - // When navigating to a previous stack, our previous sibling is the last + // When navigating to a previous block stack, our previous sibling is the last // block in it. - if (delta < 0 && result) { + if (delta < 0 && result instanceof BlockSvg) { return result.lastConnectionInStack(false)?.getSourceBlock() ?? result; } diff --git a/core/keyboard_nav/comment_bar_button_navigation_policy.ts b/core/keyboard_nav/comment_bar_button_navigation_policy.ts new file mode 100644 index 000000000..f676f4655 --- /dev/null +++ b/core/keyboard_nav/comment_bar_button_navigation_policy.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CommentBarButton} from '../comments/comment_bar_button.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from a CommentBarButton. + */ +export class CommentBarButtonNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given CommentBarButton. + * + * @param _current The CommentBarButton to return the first child of. + * @returns Null. + */ + getFirstChild(_current: CommentBarButton): IFocusableNode | null { + return null; + } + + /** + * Returns the parent of the given CommentBarButton. + * + * @param current The CommentBarButton to return the parent of. + * @returns The parent comment of the given CommentBarButton. + */ + getParent(current: CommentBarButton): IFocusableNode | null { + return current.getParentComment(); + } + + /** + * Returns the next peer node of the given CommentBarButton. + * + * @param current The CommentBarButton to find the following element of. + * @returns The next CommentBarButton, if any. + */ + getNextSibling(current: CommentBarButton): IFocusableNode | null { + const children = current.getParentComment().view.getCommentBarButtons(); + const currentIndex = children.indexOf(current); + if (currentIndex >= 0 && currentIndex + 1 < children.length) { + return children[currentIndex + 1]; + } + return null; + } + + /** + * Returns the previous peer node of the given CommentBarButton. + * + * @param current The CommentBarButton to find the preceding element of. + * @returns The CommentBarButton's previous CommentBarButton, if any. + */ + getPreviousSibling(current: CommentBarButton): IFocusableNode | null { + const children = current.getParentComment().view.getCommentBarButtons(); + const currentIndex = children.indexOf(current); + if (currentIndex > 0) { + return children[currentIndex - 1]; + } + return null; + } + + /** + * Returns whether or not the given CommentBarButton can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given CommentBarButton can be focused. + */ + isNavigable(current: CommentBarButton): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an CommentBarButton. + */ + isApplicable(current: any): current is CommentBarButton { + return current instanceof CommentBarButton; + } +} diff --git a/core/keyboard_nav/line_cursor.ts b/core/keyboard_nav/line_cursor.ts index 89668dedb..aeb80cff1 100644 --- a/core/keyboard_nav/line_cursor.ts +++ b/core/keyboard_nav/line_cursor.ts @@ -14,6 +14,7 @@ */ import {BlockSvg} from '../block_svg.js'; +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; import {Field} from '../field.js'; import {getFocusManager} from '../focus_manager.js'; import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; @@ -38,11 +39,11 @@ export class LineCursor extends Marker { } /** - * Moves the cursor to the next previous connection, next connection or block - * in the pre order traversal. Finds the next node in the pre order traversal. + * Moves the cursor to the next block or workspace comment in the pre-order + * traversal. * - * @returns The next node, or null if the current node is - * not set or there is no next value. + * @returns The next node, or null if the current node is not set or there is + * no next value. */ next(): IFocusableNode | null { const curNode = this.getCurNode(); @@ -53,8 +54,9 @@ export class LineCursor extends Marker { curNode, (candidate: IFocusableNode | null) => { return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment ); }, true, @@ -87,11 +89,11 @@ export class LineCursor extends Marker { return newNode; } /** - * Moves the cursor to the previous next connection or previous connection in - * the pre order traversal. + * Moves the cursor to the previous block or workspace comment in the + * pre-order traversal. * - * @returns The previous node, or null if the current node - * is not set or there is no previous value. + * @returns The previous node, or null if the current node is not set or there + * is no previous value. */ prev(): IFocusableNode | null { const curNode = this.getCurNode(); @@ -102,8 +104,9 @@ export class LineCursor extends Marker { curNode, (candidate: IFocusableNode | null) => { return ( - candidate instanceof BlockSvg && - !candidate.outputConnection?.targetBlock() + (candidate instanceof BlockSvg && + !candidate.outputConnection?.targetBlock()) || + candidate instanceof RenderedWorkspaceComment ); }, true, diff --git a/core/keyboard_nav/workspace_comment_navigation_policy.ts b/core/keyboard_nav/workspace_comment_navigation_policy.ts new file mode 100644 index 000000000..7fe70cead --- /dev/null +++ b/core/keyboard_nav/workspace_comment_navigation_policy.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {RenderedWorkspaceComment} from '../comments/rendered_workspace_comment.js'; +import type {IFocusableNode} from '../interfaces/i_focusable_node.js'; +import type {INavigationPolicy} from '../interfaces/i_navigation_policy.js'; +import {navigateStacks} from './block_navigation_policy.js'; + +/** + * Set of rules controlling keyboard navigation from an RenderedWorkspaceComment. + */ +export class WorkspaceCommentNavigationPolicy + implements INavigationPolicy +{ + /** + * Returns the first child of the given workspace comment. + * + * @param current The workspace comment to return the first child of. + * @returns The first child button of the given comment. + */ + getFirstChild(current: RenderedWorkspaceComment): IFocusableNode | null { + return current.view.getCommentBarButtons()[0]; + } + + /** + * Returns the parent of the given workspace comment. + * + * @param current The workspace comment to return the parent of. + * @returns The parent workspace of the given comment. + */ + getParent(current: RenderedWorkspaceComment): IFocusableNode | null { + return current.workspace; + } + + /** + * Returns the next peer node of the given workspace comment. + * + * @param current The workspace comment to find the following element of. + * @returns The next workspace comment or block stack, if any. + */ + getNextSibling(current: RenderedWorkspaceComment): IFocusableNode | null { + return navigateStacks(current, 1); + } + + /** + * Returns the previous peer node of the given workspace comment. + * + * @param current The workspace comment to find the preceding element of. + * @returns The previous workspace comment or block stack, if any. + */ + getPreviousSibling(current: RenderedWorkspaceComment): IFocusableNode | null { + return navigateStacks(current, -1); + } + + /** + * Returns whether or not the given workspace comment can be navigated to. + * + * @param current The instance to check for navigability. + * @returns True if the given workspace comment can be focused. + */ + isNavigable(current: RenderedWorkspaceComment): boolean { + return current.canBeFocused(); + } + + /** + * Returns whether the given object can be navigated from by this policy. + * + * @param current The object to check if this policy applies to. + * @returns True if the object is an RenderedWorkspaceComment. + */ + isApplicable(current: any): current is RenderedWorkspaceComment { + return current instanceof RenderedWorkspaceComment; + } +} diff --git a/core/navigator.ts b/core/navigator.ts index 77bb64cd8..2f095f6f9 100644 --- a/core/navigator.ts +++ b/core/navigator.ts @@ -7,9 +7,11 @@ import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {INavigationPolicy} from './interfaces/i_navigation_policy.js'; import {BlockNavigationPolicy} from './keyboard_nav/block_navigation_policy.js'; +import {CommentBarButtonNavigationPolicy} from './keyboard_nav/comment_bar_button_navigation_policy.js'; import {ConnectionNavigationPolicy} from './keyboard_nav/connection_navigation_policy.js'; import {FieldNavigationPolicy} from './keyboard_nav/field_navigation_policy.js'; import {IconNavigationPolicy} from './keyboard_nav/icon_navigation_policy.js'; +import {WorkspaceCommentNavigationPolicy} from './keyboard_nav/workspace_comment_navigation_policy.js'; import {WorkspaceNavigationPolicy} from './keyboard_nav/workspace_navigation_policy.js'; type RuleList = INavigationPolicy[]; @@ -29,6 +31,8 @@ export class Navigator { new ConnectionNavigationPolicy(), new WorkspaceNavigationPolicy(), new IconNavigationPolicy(), + new WorkspaceCommentNavigationPolicy(), + new CommentBarButtonNavigationPolicy(), ]; /** diff --git a/core/utils/rect.ts b/core/utils/rect.ts index c7da2a686..5a6822633 100644 --- a/core/utils/rect.ts +++ b/core/utils/rect.ts @@ -32,6 +32,16 @@ export class Rect { public right: number, ) {} + /** + * Converts a DOM or SVG Rect to a Blockly Rect. + * + * @param rect The rectangle to convert. + * @returns A representation of the same rectangle as a Blockly Rect. + */ + static from(rect: DOMRect | SVGRect): Rect { + return new Rect(rect.y, rect.y + rect.height, rect.x, rect.x + rect.width); + } + /** * Creates a new copy of this rectangle. * @@ -51,6 +61,11 @@ export class Rect { return this.right - this.left; } + /** Returns the top left coordinate of this rectangle. */ + getOrigin(): Coordinate { + return new Coordinate(this.left, this.top); + } + /** * Tests whether this rectangle contains a x/y coordinate. * diff --git a/core/workspace.ts b/core/workspace.ts index f7b866447..5f2051939 100644 --- a/core/workspace.ts +++ b/core/workspace.ts @@ -21,6 +21,7 @@ import * as common from './common.js'; import type {ConnectionDB} from './connection_db.js'; import type {Abstract} from './events/events_abstract.js'; import * as eventUtils from './events/utils.js'; +import type {IBoundedElement} from './interfaces/i_bounded_element.js'; import type {IConnectionChecker} from './interfaces/i_connection_checker.js'; import {IProcedureMap} from './interfaces/i_procedure_map.js'; import type {IVariableMap} from './interfaces/i_variable_map.js'; @@ -35,6 +36,7 @@ import * as arrayUtils from './utils/array.js'; import * as deprecation from './utils/deprecation.js'; import * as idGenerator from './utils/idgenerator.js'; import * as math from './utils/math.js'; +import {Rect} from './utils/rect.js'; import type * as toolbox from './utils/toolbox.js'; import {deleteVariable, getVariableUsesById} from './variables.js'; @@ -181,10 +183,31 @@ export class Workspace { a: Block | WorkspaceComment, b: Block | WorkspaceComment, ): number { + const wrap = (element: Block | WorkspaceComment) => { + return { + getBoundingRectangle: () => { + const xy = element.getRelativeToSurfaceXY(); + return new Rect(xy.y, xy.y, xy.x, xy.x); + }, + moveBy: () => {}, + }; + }; + return this.sortByOrigin(wrap(a), wrap(b)); + } + + /** + * Sorts bounded elements on the workspace by their relative position, top to + * bottom (with slight LTR or RTL bias). + * + * @param a The first element to sort. + * @param b The second elment to sort. + * @returns -1, 0 or 1 depending on the sort order. + */ + protected sortByOrigin(a: IBoundedElement, b: IBoundedElement): number { const offset = Math.sin(math.toRadians(Workspace.SCAN_ANGLE)) * (this.RTL ? -1 : 1); - const aXY = a.getRelativeToSurfaceXY(); - const bXY = b.getRelativeToSurfaceXY(); + const aXY = a.getBoundingRectangle().getOrigin(); + const bXY = b.getBoundingRectangle().getOrigin(); return aXY.y + offset * aXY.x - (bXY.y + offset * bXY.x); } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 3033eacd7..00eef5653 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -22,7 +22,9 @@ import type {Block} from './block.js'; import type {BlockSvg} from './block_svg.js'; import type {BlocklyOptions} from './blockly_options.js'; import * as browserEvents from './browser_events.js'; +import {COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/collapse_comment_bar_button.js'; import {COMMENT_EDITOR_FOCUS_IDENTIFIER} from './comments/comment_editor.js'; +import {COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER} from './comments/delete_comment_bar_button.js'; import {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js'; import {WorkspaceComment} from './comments/workspace_comment.js'; import * as common from './common.js'; @@ -2266,8 +2268,8 @@ export class WorkspaceSvg * * @param comment comment to add. */ - override addTopComment(comment: WorkspaceComment) { - this.addTopBoundedElement(comment as RenderedWorkspaceComment); + override addTopComment(comment: RenderedWorkspaceComment) { + this.addTopBoundedElement(comment); super.addTopComment(comment); } @@ -2276,11 +2278,31 @@ export class WorkspaceSvg * * @param comment comment to remove. */ - override removeTopComment(comment: WorkspaceComment) { - this.removeTopBoundedElement(comment as RenderedWorkspaceComment); + override removeTopComment(comment: RenderedWorkspaceComment) { + this.removeTopBoundedElement(comment); super.removeTopComment(comment); } + /** + * Returns a list of comments on this workspace. + * + * @param ordered If true, sorts the comments based on their position. + * @returns A list of workspace comments. + */ + override getTopComments(ordered = false): RenderedWorkspaceComment[] { + return super.getTopComments(ordered) as RenderedWorkspaceComment[]; + } + + /** + * Returns the workspace comment with the given ID, if any. + * + * @param id The ID of the comment to retrieve. + * @returns The workspace comment with the given ID, or null. + */ + override getCommentById(id: string): RenderedWorkspaceComment | null { + return super.getCommentById(id) as RenderedWorkspaceComment | null; + } + override getRootWorkspace(): WorkspaceSvg | null { return super.getRootWorkspace() as WorkspaceSvg | null; } @@ -2308,8 +2330,15 @@ export class WorkspaceSvg * * @returns The top-level bounded elements. */ - getTopBoundedElements(): IBoundedElement[] { - return new Array().concat(this.topBoundedElements); + getTopBoundedElements(ordered = false): IBoundedElement[] { + const elements = new Array().concat( + this.topBoundedElements, + ); + if (ordered) { + elements.sort(this.sortByOrigin.bind(this)); + } + + return elements; } /** @@ -2794,19 +2823,32 @@ export class WorkspaceSvg return null; } - // Search for a specific workspace comment editor - // (only if id seems like it is one). - const commentEditorIndicator = id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER); - if (commentEditorIndicator !== -1) { - const commentId = id.substring(0, commentEditorIndicator); + // Search for a specific workspace comment or comment icon if the ID + // indicates the presence of one. + const commentIdSeparatorIndex = Math.max( + id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER), + id.indexOf(COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER), + id.indexOf(COMMENT_DELETE_BAR_BUTTON_FOCUS_IDENTIFIER), + ); + if (commentIdSeparatorIndex !== -1) { + const commentId = id.substring(0, commentIdSeparatorIndex); const comment = this.searchForWorkspaceComment(commentId); if (comment) { - return comment.getEditorFocusableNode(); + if (id.indexOf(COMMENT_EDITOR_FOCUS_IDENTIFIER) > -1) { + return comment.getEditorFocusableNode(); + } else { + return ( + comment.view + .getCommentBarButtons() + .find((button) => button.getFocusableElement().id.includes(id)) ?? + null + ); + } } } // Search for a specific block. - // Don't use `getBlockById` because the block ID is not guaranteeed + // Don't use `getBlockById` because the block ID is not guaranteed // to be globally unique, but the ID on the focusable element is. const block = this.getAllBlocks(false).find( (block) => block.getFocusableElement().id === id, From 5acd072f0519f61848d86b148bdd44537fec9c0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:49:04 -0700 Subject: [PATCH 11/17] chore(deps): bump prettier from 3.6.0 to 3.6.2 (#9185) Bumps [prettier](https://github.com/prettier/prettier) from 3.6.0 to 3.6.2. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.6.0...3.6.2) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.6.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a778f6d83..28f9b3bd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7717,11 +7717,10 @@ } }, "node_modules/prettier": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.0.tgz", - "integrity": "sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, From 1e37d21f0ae32ef2c7f5bfd0f7e71a3537c8ab77 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Jul 2025 16:07:05 -0700 Subject: [PATCH 12/17] fix: Ensure focus changes when tabbing fields (#9173) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes https://github.com/google/blockly-keyboard-experimentation/issues/578 Fixes part of #8915 ### Proposed Changes Ensures fields update focus to the next field when tabbing between field editors. The old behavior can be observed in [#578](https://github.com/google/blockly-keyboard-experimentation/issues/578) and the new behavior can be observed here: [Screen recording 2025-06-25 1.39.28 PM.webm](https://github.com/user-attachments/assets/e00fcb55-5c20-4d5c-81a8-be9049cc0d7e) ### Reason for Changes Having focus reset back to the original field editor that was opened is an unexpected experience for users. This approach is better. Note that there are some separate changes added here, as well: - Connections and fields now check if their block IDs contain their indicator prefixes since this will all-but-guarantee focus breaks for those nodes. This is an excellent example of why #9171 is needed. - Some minor naming updates for `FieldInput`: it was incorrectly implying that key events are sent for changes to the `input` element used by the field editor, but they are actually `InputEvent`s per https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event. ### Test Coverage New tests were added for field editing in general (since this seems to be missing), including tabbing support to ensure the fixes originally introduced in #9049. One new test has been added specifically for verifying that focus updates with tabbing. This has been verified to fail with the fix removed (as have all tabbing tests with the tabbing code from #9049 removed). Some specific notes for the test changes: - There's a slight test dependency inversion happening here. `FieldInput` is being tested with a specific `FieldNumber` class via real block loading. This isn't ideal, but it seems fine given the circumstances (otherwise a lot of extra setup would be necessary for the tests). - The workspace actually needs to be made visible during tests in order for focus to work correctly (though it's reset at the end of each test, but this may cause some flickering while the tests are running). - It's the case that a bunch of tests were actually setting up blocks incorrectly (i.e. not defining a must-have `id` property which caused some issues with the new field and connection ID validation checks). These tests have been corrected, but it's worth noting that the blocks are likely still technically wrong since they are not conforming to their TypeScript contracts. - Similar to the previous point, one test was incorrectly setting the first ID to be returned by the ID generator as `undefined` since (presumably due to a copy-and-paste error when the test was introduced) it was referencing a `TEST_BLOCK_ID` property that hadn't been defined for that test suite. This has been corrected as, without it, there are failures due to the new validation checks. - For the connection database checks, a new ID is generated instead of fixing the block ID to ensure that it's always unique even if called multiple times (otherwise a block ID would need to be piped through from the calling tests, or an invalid situation would need to be introduced where multiple blocks shared an ID; the former seemed unnecessary and the latter seemed nonideal). - There are distinct Geras/Zelos tests to validate the case where a full-block field should have its parent block, rather than the field itself, focused on tabbing. See this conversation for more context: https://github.com/google/blockly/pull/9173#discussion_r2172921455. ### Documentation No documentation changes should be needed here. ### Additional Information Nothing to add. --- core/connection.ts | 6 + core/field.ts | 6 + core/field_input.ts | 23 +- tests/mocha/connection_checker_test.js | 47 +++- tests/mocha/connection_db_test.js | 3 +- tests/mocha/event_test.js | 1 + tests/mocha/field_textinput_test.js | 296 +++++++++++++++++++++++++ 7 files changed, 364 insertions(+), 18 deletions(-) diff --git a/core/connection.ts b/core/connection.ts index fbd094dba..a79b7b9b1 100644 --- a/core/connection.ts +++ b/core/connection.ts @@ -83,6 +83,12 @@ export class Connection { public type: number, ) { this.sourceBlock_ = source; + if (source.id.includes('_connection')) { + throw new Error( + `Connection ID indicator is contained in block ID. This will cause ` + + `problems with focus: ${source.id}.`, + ); + } this.id = `${source.id}_connection_${idGenerator.getNextUniqueId()}`; } diff --git a/core/field.ts b/core/field.ts index c4b651478..fdcb2d693 100644 --- a/core/field.ts +++ b/core/field.ts @@ -265,6 +265,12 @@ export abstract class Field throw Error('Field already bound to a block'); } this.sourceBlock_ = block; + if (block.id.includes('_field')) { + throw new Error( + `Field ID indicator is contained in block ID. This may cause ` + + `problems with focus: ${block.id}.`, + ); + } this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`; } diff --git a/core/field_input.ts b/core/field_input.ts index c7921d6f0..b68530918 100644 --- a/core/field_input.ts +++ b/core/field_input.ts @@ -27,6 +27,7 @@ import { FieldValidator, UnattachedFieldError, } from './field.js'; +import {getFocusManager} from './focus_manager.js'; import type {IFocusableNode} from './interfaces/i_focusable_node.js'; import {Msg} from './msg.js'; import * as renderManagement from './render_management.js'; @@ -83,8 +84,8 @@ export abstract class FieldInput extends Field< /** Key down event data. */ private onKeyDownWrapper: browserEvents.Data | null = null; - /** Key input event data. */ - private onKeyInputWrapper: browserEvents.Data | null = null; + /** Input element input event data. */ + private onInputWrapper: browserEvents.Data | null = null; /** * Whether the field should consider the whole parent block to be its click @@ -558,7 +559,7 @@ export abstract class FieldInput extends Field< this.onHtmlInputKeyDown_, ); // Resize after every input change. - this.onKeyInputWrapper = browserEvents.conditionalBind( + this.onInputWrapper = browserEvents.conditionalBind( htmlInput, 'input', this, @@ -572,9 +573,9 @@ export abstract class FieldInput extends Field< browserEvents.unbind(this.onKeyDownWrapper); this.onKeyDownWrapper = null; } - if (this.onKeyInputWrapper) { - browserEvents.unbind(this.onKeyInputWrapper); - this.onKeyInputWrapper = null; + if (this.onInputWrapper) { + browserEvents.unbind(this.onInputWrapper); + this.onInputWrapper = null; } } @@ -614,6 +615,14 @@ export abstract class FieldInput extends Field< if (target instanceof FieldInput) { WidgetDiv.hideIfOwner(this); dropDownDiv.hideWithoutAnimation(); + const targetSourceBlock = target.getSourceBlock(); + if ( + target.isFullBlockField() && + targetSourceBlock && + targetSourceBlock instanceof BlockSvg + ) { + getFocusManager().focusNode(targetSourceBlock); + } else getFocusManager().focusNode(target); target.showEditor(); } } @@ -622,7 +631,7 @@ export abstract class FieldInput extends Field< /** * Handle a change to the editor. * - * @param _e Keyboard event. + * @param _e InputEvent. */ private onHtmlInputChange(_e: Event) { // Intermediate value changes from user input are not confirmed until the diff --git a/tests/mocha/connection_checker_test.js b/tests/mocha/connection_checker_test.js index f353a2b77..fee2966d7 100644 --- a/tests/mocha/connection_checker_test.js +++ b/tests/mocha/connection_checker_test.js @@ -29,7 +29,10 @@ suite('Connection checker', function () { } test('Target Null', function () { - const connection = new Blockly.Connection({}, ConnectionType.INPUT_VALUE); + const connection = new Blockly.Connection( + {id: 'test'}, + ConnectionType.INPUT_VALUE, + ); assertReasonHelper( this.checker, connection, @@ -38,7 +41,7 @@ suite('Connection checker', function () { ); }); test('Target Self', function () { - const block = {workspace: 1}; + const block = {id: 'test', workspace: 1}; const connection1 = new Blockly.Connection( block, ConnectionType.INPUT_VALUE, @@ -57,11 +60,11 @@ suite('Connection checker', function () { }); test('Different Workspaces', function () { const connection1 = new Blockly.Connection( - {workspace: 1}, + {id: 'test1', workspace: 1}, ConnectionType.INPUT_VALUE, ); const connection2 = new Blockly.Connection( - {workspace: 2}, + {id: 'test2', workspace: 2}, ConnectionType.OUTPUT_VALUE, ); @@ -76,10 +79,10 @@ suite('Connection checker', function () { setup(function () { // We have to declare each separately so that the connections belong // on different blocks. - const prevBlock = {isShadow: function () {}}; - const nextBlock = {isShadow: function () {}}; - const outBlock = {isShadow: function () {}}; - const inBlock = {isShadow: function () {}}; + const prevBlock = {id: 'test1', isShadow: function () {}}; + const nextBlock = {id: 'test2', isShadow: function () {}}; + const outBlock = {id: 'test3', isShadow: function () {}}; + const inBlock = {id: 'test4', isShadow: function () {}}; this.previous = new Blockly.Connection( prevBlock, ConnectionType.PREVIOUS_STATEMENT, @@ -197,11 +200,13 @@ suite('Connection checker', function () { suite('Shadows', function () { test('Previous Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return true; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return false; }, @@ -224,11 +229,13 @@ suite('Connection checker', function () { }); test('Next Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return false; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -251,11 +258,13 @@ suite('Connection checker', function () { }); test('Prev and Next Shadow', function () { const prevBlock = { + id: 'test1', isShadow: function () { return true; }, }; const nextBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -278,11 +287,13 @@ suite('Connection checker', function () { }); test('Output Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return true; }, }; const inBlock = { + id: 'test2', isShadow: function () { return false; }, @@ -305,11 +316,13 @@ suite('Connection checker', function () { }); test('Input Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return false; }, }; const inBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -332,11 +345,13 @@ suite('Connection checker', function () { }); test('Output and Input Shadow', function () { const outBlock = { + id: 'test1', isShadow: function () { return true; }, }; const inBlock = { + id: 'test2', isShadow: function () { return true; }, @@ -373,9 +388,11 @@ suite('Connection checker', function () { }; test('Output connected, adding previous', function () { const outBlock = { + id: 'test1', isShadow: function () {}, }; const inBlock = { + id: 'test2', isShadow: function () {}, }; const outCon = new Blockly.Connection( @@ -394,6 +411,7 @@ suite('Connection checker', function () { ConnectionType.PREVIOUS_STATEMENT, ); const nextBlock = { + id: 'test3', isShadow: function () {}, }; const nextCon = new Blockly.Connection( @@ -410,9 +428,11 @@ suite('Connection checker', function () { }); test('Previous connected, adding output', function () { const prevBlock = { + id: 'test1', isShadow: function () {}, }; const nextBlock = { + id: 'test2', isShadow: function () {}, }; const prevCon = new Blockly.Connection( @@ -431,6 +451,7 @@ suite('Connection checker', function () { ConnectionType.OUTPUT_VALUE, ); const inBlock = { + id: 'test3', isShadow: function () {}, }; const inCon = new Blockly.Connection( @@ -449,8 +470,14 @@ suite('Connection checker', function () { }); suite('Check Types', function () { setup(function () { - this.con1 = new Blockly.Connection({}, ConnectionType.PREVIOUS_STATEMENT); - this.con2 = new Blockly.Connection({}, ConnectionType.NEXT_STATEMENT); + this.con1 = new Blockly.Connection( + {id: 'test1'}, + ConnectionType.PREVIOUS_STATEMENT, + ); + this.con2 = new Blockly.Connection( + {id: 'test2'}, + ConnectionType.NEXT_STATEMENT, + ); }); function assertCheckTypes(checker, one, two) { assert.isTrue(checker.doTypeChecks(one, two)); diff --git a/tests/mocha/connection_db_test.js b/tests/mocha/connection_db_test.js index e7f397d54..04f685124 100644 --- a/tests/mocha/connection_db_test.js +++ b/tests/mocha/connection_db_test.js @@ -5,6 +5,7 @@ */ import {ConnectionType} from '../../build/src/core/connection_type.js'; +import * as idGenerator from '../../build/src/core/utils/idgenerator.js'; import {assert} from '../../node_modules/chai/chai.js'; import { sharedTestSetup, @@ -31,7 +32,7 @@ suite('Connection Database', function () { }; workspace.connectionDBList[type] = opt_database || this.database; const connection = new Blockly.RenderedConnection( - {workspace: workspace}, + {id: idGenerator.getNextUniqueId(), workspace: workspace}, type, ); connection.x = x; diff --git a/tests/mocha/event_test.js b/tests/mocha/event_test.js index 00d704ff0..7423f22f7 100644 --- a/tests/mocha/event_test.js +++ b/tests/mocha/event_test.js @@ -355,6 +355,7 @@ suite('Events', function () { suite('With variable getter blocks', function () { setup(function () { + this.TEST_BLOCK_ID = 'test_block_id'; this.genUidStub = createGenUidStubWithReturns([ this.TEST_BLOCK_ID, 'test_var_id', diff --git a/tests/mocha/field_textinput_test.js b/tests/mocha/field_textinput_test.js index 82c1a645e..7dc105f72 100644 --- a/tests/mocha/field_textinput_test.js +++ b/tests/mocha/field_textinput_test.js @@ -294,4 +294,300 @@ suite('Text Input Fields', function () { this.assertValue('test text'); }); }); + + suite('Use editor', function () { + setup(function () { + this.blockJson = { + 'type': 'math_arithmetic', + 'id': 'test_arithmetic_block', + 'fields': { + 'OP': 'ADD', + }, + 'inputs': { + 'A': { + 'shadow': { + 'type': 'math_number', + 'id': 'left_input_block', + 'name': 'test_name', + 'fields': { + 'NUM': 1, + }, + }, + }, + 'B': { + 'shadow': { + 'type': 'math_number', + 'id': 'right_input_block', + 'fields': { + 'NUM': 2, + }, + }, + }, + }, + }; + + this.getFieldFromShadowBlock = function (shadowBlock) { + return shadowBlock.getFields().next().value; + }; + + this.simulateTypingIntoInput = (inputElem, newText) => { + // Typing into an input field changes its value directly and then fires + // an InputEvent (which FieldInput relies on to automatically + // synchronize its state). + inputElem.value = newText; + inputElem.dispatchEvent(new InputEvent('input')); + }; + }); + + // The block being tested doesn't use full-block fields in Geras. + suite('Geras theme', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'geras', + }); + Blockly.serialization.blocks.append(this.blockJson, this.workspace); + + // The workspace actually needs to be visible for focus. + document.getElementById('blocklyDiv').style.visibility = 'visible'; + }); + teardown(function () { + document.getElementById('blocklyDiv').style.visibility = 'hidden'; + workspaceTeardown.call(this, this.workspace); + }); + + test('No editor open by default', function () { + // The editor is only opened if its indicated that it should be open. + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Type in editor with escape does not change field value', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, 'updated value'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + }), + ); + + // 'Escape' will avoid saving the edited field value and close the editor. + assert.equal(field.getValue(), 1); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Type in editor with enter changes field value', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // 'Enter' will save the edited result and close the editor. + assert.equal(field.getValue(), 10); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Not finishing editing does not return ephemeral focus', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + Blockly.getFocusManager().focusNode(field); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + + // If the editor doesn't restore focus then the current focused element is + // still the editor. + assert.strictEqual(document.activeElement, fieldEditor); + }); + + test('Finishing editing returns ephemeral focus', async function () { + const block = this.workspace.getBlockById('left_input_block'); + const field = this.getFieldFromShadowBlock(block); + Blockly.getFocusManager().focusNode(field); + field.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Change the value of the field's input through its editor. + const fieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(fieldEditor, '10'); + fieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + }), + ); + + // Verify that exiting the editor restores focus back to the field. + assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), field); + assert.strictEqual(document.activeElement, field.getFocusableElement()); + }); + + test('Opening an editor, tabbing, then editing changes the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that only the right field changed (due to the tab). + assert.equal(leftField.getValue(), 1); + assert.equal(rightField.getValue(), 15); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Opening an editor, tabbing, then editing changes focus to the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + Blockly.getFocusManager().focusNode(leftField); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that the tab causes focus to change to the right field. + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + rightField, + ); + assert.strictEqual( + document.activeElement, + rightField.getFocusableElement(), + ); + }); + }); + + // The block being tested uses full-block fields in Zelos. + suite('Zelos theme', function () { + setup(function () { + this.workspace = Blockly.inject('blocklyDiv', { + renderer: 'zelos', + }); + Blockly.serialization.blocks.append(this.blockJson, this.workspace); + + // The workspace actually needs to be visible for focus. + document.getElementById('blocklyDiv').style.visibility = 'visible'; + }); + teardown(function () { + document.getElementById('blocklyDiv').style.visibility = 'hidden'; + workspaceTeardown.call(this, this.workspace); + }); + + test('Opening an editor, tabbing, then editing full block field changes the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + const rightField = this.getFieldFromShadowBlock(rightInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that only the right field changed (due to the tab). + assert.equal(leftField.getValue(), 1); + assert.equal(rightField.getValue(), 15); + assert.isNull(document.querySelector('.blocklyHtmlInput')); + }); + + test('Opening an editor, tabbing, then editing full block field changes focus to the second field', async function () { + const leftInputBlock = this.workspace.getBlockById('left_input_block'); + const rightInputBlock = + this.workspace.getBlockById('right_input_block'); + const leftField = this.getFieldFromShadowBlock(leftInputBlock); + Blockly.getFocusManager().focusNode(leftInputBlock); + leftField.showEditor(); + // This must be called to avoid editor resize logic throwing an error. + await Blockly.renderManagement.finishQueuedRenders(); + + // Tab, then edit and close the editor. + document.querySelector('.blocklyHtmlInput').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + }), + ); + const rightFieldEditor = document.querySelector('.blocklyHtmlInput'); + this.simulateTypingIntoInput(rightFieldEditor, '15'); + rightFieldEditor.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + }), + ); + + // Verify that the tab causes focus to change to the right field block. + assert.strictEqual( + Blockly.getFocusManager().getFocusedNode(), + rightInputBlock, + ); + assert.strictEqual( + document.activeElement, + rightInputBlock.getFocusableElement(), + ); + }); + }); + }); }); From 4c78c1d4a31fd2474737a3068c11d11881ae954e Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 2 Jul 2025 16:11:50 -0700 Subject: [PATCH 13/17] fix: Auto close drop-down divs on lost focus (#9175) ## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes https://github.com/google/blockly-keyboard-experimentation/issues/563 ### Proposed Changes This introduces support in `FocusManager` to receive feedback on when an ephemerally focused element entirely loses focus (that is, neither it nor any of its descendants have focus). This also introduces a behavior change for drop-down divs using the previously mentioned functionality to automatically close themselves when they lose focus for any reason (e.g. clicking outside of the div or tab navigating away from it). Finally, and **importantly**, this adds a case where ephemeral focus does _not_ automatically return to the previously focused node: when focus is lost to the ephemerally focused element's tree and isn't instead put on another focused node. ### Reason for Changes Ultimately, focus is probably the best proxy for cases when a drop-down div ought to no longer be open. However, tracking focus only within the scope of the drop-down div utility is rather difficult since a lot of the same problems that `FocusManager` handles also occur here (with regards to both descendants and outside elements receiving focus). It made more sense to expand `FocusManager`'s ephemeral focus support: - It was easier to implement this `FocusManager` and in a way that's much more robust (since it's leveraging existing event handlers). - Using `FocusManager` trivialized the solution for drop-down divs. - There could be other use cases where custom ephemeral focus uses might benefit from knowing when they lose focus. This new support is enabled by default for all drop-down divs, but can be disabled by callers if they wish to revert to the previous behavior of not auto-closing. The change for whether to restore ephemeral focus was needed to fix a drawback that arises from the automatic returning of ephemeral focus introduced in this PR: when a user clicks out of an open drop-down menu it will restore focus back to the node that held focus prior to taking ephemeral focus (since it properly hides the drop-down div and restores focus). This creates awkward behavior issues for both mouse and keyboard users: - For mouse: trying to open a drop-down outside of Blockly will automatically close the drop-down when the Blockly drop-down finishes closing (since focus is stolen back away from the thing the user clicked on). - For keyboard: tab navigating out of Blockly tries to force focus back to Blockly. ### Test Coverage New tests have been added for both the drop-down div and `FocusManager` components, and have been verified as failing without the new behaviors in place. There may be other edge cases worth testing for `FocusManager` in particular, but the tests introduced in this PR seem to cover the most important cases. Demonstration of the new behavior: [Screen recording 2025-07-01 6.28.37 PM.webm](https://github.com/user-attachments/assets/7af29fed-1ba1-4828-a6cd-65bb94509e72) ### Documentation No new documentation changes seem needed beyond the code documentation updates. ### Additional Information It's also possible to change the automatic restoration behavior to be conditional instead of always assuming focus shouldn't be reset if focus leaves the ephemeral element, but that's probably a better change if there's an actual user issue discovered with this approach. --- core/dropdowndiv.ts | 36 ++++++- core/focus_manager.ts | 88 ++++++++++++++-- tests/mocha/dropdowndiv_test.js | 72 ++++++++++++- tests/mocha/focus_manager_test.js | 167 ++++++++++++++++++++++++++++++ tests/mocha/index.html | 8 +- 5 files changed, 358 insertions(+), 13 deletions(-) diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index ceab467a8..608fe9b5b 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -213,6 +213,8 @@ export function setColour(backgroundColour: string, borderColour: string) { * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByBlock( @@ -221,11 +223,13 @@ export function showPositionedByBlock( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, + autoCloseOnLostFocus: boolean = true, ): boolean { return showPositionedByRect( getScaledBboxOfBlock(block), field as Field, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -245,6 +249,8 @@ export function showPositionedByBlock( * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByField( @@ -252,12 +258,14 @@ export function showPositionedByField( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, + autoCloseOnLostFocus: boolean = true, ): boolean { positionToField = true; return showPositionedByRect( getScaledBboxOfField(field as Field), field as Field, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -302,12 +310,15 @@ function getScaledBboxOfField(field: Field): Rect { * according to the drop-down div's lifetime. Note that if a false value is * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( bBox: Rect, field: Field, manageEphemeralFocus: boolean, + autoCloseOnLostFocus: boolean, opt_onHide?: () => void, opt_secondaryYOffset?: number, ): boolean { @@ -335,6 +346,7 @@ function showPositionedByRect( secondaryX, secondaryY, manageEphemeralFocus, + autoCloseOnLostFocus, opt_onHide, ); } @@ -357,6 +369,8 @@ function showPositionedByRect( * @param opt_onHide Optional callback for when the drop-down is hidden. * @param manageEphemeralFocus Whether ephemeral focus should be managed * according to the widget div's lifetime. + * @param autoCloseOnLostFocus Whether the drop-down should automatically hide + * if it loses DOM focus for any reason. * @returns True if the menu rendered at the primary origin point. * @internal */ @@ -368,6 +382,7 @@ export function show( secondaryX: number, secondaryY: number, manageEphemeralFocus: boolean, + autoCloseOnLostFocus: boolean, opt_onHide?: () => void, ): boolean { owner = newOwner as Field; @@ -394,7 +409,18 @@ export function show( // Ephemeral focus must happen after the div is fully visible in order to // ensure that it properly receives focus. if (manageEphemeralFocus) { - returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); + const autoCloseCallback = autoCloseOnLostFocus + ? (hasFocus: boolean) => { + // If focus is ever lost, close the drop-down. + if (!hasFocus) { + hide(); + } + } + : null; + returnEphemeralFocus = getFocusManager().takeEphemeralFocus( + div, + autoCloseCallback, + ); } return atOrigin; @@ -693,7 +719,6 @@ export function hideWithoutAnimation() { onHide(); onHide = null; } - clearContent(); owner = null; (common.getMainWorkspace() as WorkspaceSvg).markFocused(); @@ -702,6 +727,13 @@ export function hideWithoutAnimation() { returnEphemeralFocus(); returnEphemeralFocus = null; } + + // Content must be cleared after returning ephemeral focus since otherwise it + // may force focus changes which could desynchronize the focus manager and + // make it think the user directed focus away from the drop-down div (which + // will then notify it to not restore focus back to any previously focused + // node). + clearContent(); } /** diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 02e059107..31453b827 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -17,6 +17,14 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; */ export type ReturnEphemeralFocus = () => void; +/** + * Type declaration for an optional callback to observe when an element with + * ephemeral focus has its DOM focus changed before ephemeral focus is returned. + * + * See FocusManager.takeEphemeralFocus for more details. + */ +export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void; + /** * Represents an IFocusableTree that has been registered for focus management in * FocusManager. @@ -78,7 +86,10 @@ export class FocusManager { private previouslyFocusedNode: IFocusableNode | null = null; private registeredTrees: Array = []; - private currentlyHoldsEphemeralFocus: boolean = false; + private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null; + private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null = + null; + private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false; private lockFocusStateChanges: boolean = false; private recentlyLostAllFocus: boolean = false; private isUpdatingFocusedNode: boolean = false; @@ -118,6 +129,21 @@ export class FocusManager { } else { this.defocusCurrentFocusedNode(); } + + const ephemeralFocusElem = this.ephemerallyFocusedElement; + if (ephemeralFocusElem) { + const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus; + const hasFocus = + !!element && + element instanceof Node && + ephemeralFocusElem.contains(element); + if (hadFocus !== hasFocus) { + if (this.ephemeralDomFocusChangedCallback) { + this.ephemeralDomFocusChangedCallback(hasFocus); + } + this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus; + } + } }; // Register root document focus listeners for tracking when focus leaves all @@ -313,7 +339,7 @@ export class FocusManager { */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus; + const mustRestoreUpdatingNode = !this.ephemerallyFocusedElement; if (mustRestoreUpdatingNode) { // Disable state syncing from DOM events since possible calls to focus() // below will loop a call back to focusNode(). @@ -395,7 +421,7 @@ export class FocusManager { this.removeHighlight(nextTreeRoot); } - if (!this.currentlyHoldsEphemeralFocus) { + if (!this.ephemerallyFocusedElement) { // Only change the actively focused node if ephemeral state isn't held. this.activelyFocusNode(nodeToFocus, prevTree ?? null); } @@ -423,24 +449,50 @@ export class FocusManager { * the returned lambda is called. Additionally, only 1 ephemeral focus context * can be active at any given time (attempting to activate more than one * simultaneously will result in an error being thrown). + * + * Important details regarding the onFocusChangedInDom callback: + * - This method will be called initially with a value of 'true' indicating + * that the ephemeral element has been focused, so callers can rely on that, + * if needed, for initialization logic. + * - It's safe to end ephemeral focus in this callback (and is encouraged for + * callers that wish to automatically end ephemeral focus when the user + * directs focus outside of the element). + * - The element AND all of its descendants are tracked for focus. That means + * the callback will ONLY be called with a value of 'false' if focus + * completely leaves the DOM tree for the provided focusable element. + * - It's invalid to return focus on the very first call to the callback, + * however this is expected to be impossible, anyway, since this method + * won't return until after the first call to the callback (thus there will + * be no means to return ephemeral focus). + * + * @param focusableElement The element that should be focused until returned. + * @param onFocusChangedInDom An optional callback which will be notified + * whenever the provided element's focus changes before ephemeral focus is + * returned. See the details above for specifics. + * @returns A ReturnEphemeralFocus that must be called when ephemeral focus + * should end. */ takeEphemeralFocus( focusableElement: HTMLElement | SVGElement, + onFocusChangedInDom: EphemeralFocusChangedInDom | null = null, ): ReturnEphemeralFocus { this.ensureManagerIsUnlocked(); - if (this.currentlyHoldsEphemeralFocus) { + if (this.ephemerallyFocusedElement) { throw Error( `Attempted to take ephemeral focus when it's already held, ` + `with new element: ${focusableElement}.`, ); } - this.currentlyHoldsEphemeralFocus = true; + this.ephemerallyFocusedElement = focusableElement; + this.ephemeralDomFocusChangedCallback = onFocusChangedInDom; if (this.focusedNode) { this.passivelyFocusNode(this.focusedNode, null); } focusableElement.focus(); + this.ephemerallyFocusedElementCurrentlyHasFocus = true; + const focusedNodeAtStart = this.focusedNode; let hasFinishedEphemeralFocus = false; return () => { if (hasFinishedEphemeralFocus) { @@ -450,9 +502,22 @@ export class FocusManager { ); } hasFinishedEphemeralFocus = true; - this.currentlyHoldsEphemeralFocus = false; + this.ephemerallyFocusedElement = null; + this.ephemeralDomFocusChangedCallback = null; - if (this.focusedNode) { + const hadEphemeralFocusAtEnd = + this.ephemerallyFocusedElementCurrentlyHasFocus; + this.ephemerallyFocusedElementCurrentlyHasFocus = false; + + // If the user forced away DOM focus during ephemeral focus, then + // determine whether focus should be restored back to a focusable node + // after ephemeral focus ends. Generally it shouldn't be, but in some + // cases (such as the user focusing an actual focusable node) it then + // should be. + const hasNewFocusedNode = focusedNodeAtStart !== this.focusedNode; + const shouldRestoreToNode = hasNewFocusedNode || hadEphemeralFocusAtEnd; + + if (this.focusedNode && shouldRestoreToNode) { this.activelyFocusNode(this.focusedNode, null); // Even though focus was restored, check if it's lost again. It's @@ -470,6 +535,11 @@ export class FocusManager { this.focusNode(capturedNode); } }, 0); + } else { + // If the ephemeral element lost focus then do not force it back since + // that likely will override the user's own attempt to move focus away + // from the ephemeral experience. + this.defocusCurrentFocusedNode(); } }; } @@ -478,7 +548,7 @@ export class FocusManager { * @returns whether something is currently holding ephemeral focus */ ephemeralFocusTaken(): boolean { - return this.currentlyHoldsEphemeralFocus; + return !!this.ephemerallyFocusedElement; } /** @@ -516,7 +586,7 @@ export class FocusManager { // The current node will likely be defocused while ephemeral focus is held, // but internal manager state shouldn't change since the node should be // restored upon exiting ephemeral focus mode. - if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) { + if (this.focusedNode && !this.ephemerallyFocusedElement) { this.passivelyFocusNode(this.focusedNode, null); this.updateFocusedNode(null); } diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index fc792fbaf..fac8368a9 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -155,7 +155,7 @@ suite('DropDownDiv', function () { }); test('Escape dismisses DropDownDiv', function () { let hidden = false; - Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, () => { + Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, false, () => { hidden = true; }); assert.isFalse(hidden); @@ -252,6 +252,34 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); + + test('without auto close on lost focus lost focus does not hide drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, true, false); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // Even though the drop-down lost focus, it should still be visible. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + }); + + test('with auto close on lost focus lost focus hides drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByField(field, null, null, true, true); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // the drop-down should now be hidden since it lost focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); }); suite('showPositionedByBlock()', function () { @@ -325,6 +353,48 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); + + test('without auto close on lost focus lost focus does not hide drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + true, + false, + ); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // Even though the drop-down lost focus, it should still be visible. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '1'); + }); + + test('with auto close on lost focus lost focus hides drop-down div', function () { + const block = this.setUpBlockWithField(); + const field = Array.from(block.getFields())[0]; + Blockly.getFocusManager().focusNode(block); + Blockly.DropDownDiv.showPositionedByBlock( + field, + block, + null, + null, + true, + true, + ); + + // Focus an element outside of the drop-down. + document.getElementById('nonTreeElementForEphemeralFocus').focus(); + + // the drop-down should now be hidden since it lost focus. + const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); + assert.strictEqual(dropDownDivElem.style.opacity, '0'); + }); }); suite('hideWithoutAnimation()', function () { diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index 26dcb8dbe..cb4a43652 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -5975,5 +5975,172 @@ suite('FocusManager', function () { ); assert.strictEqual(document.activeElement, nodeElem); }); + + test('with focus change callback initially calls focus change callback with initial state', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + + assert.strictEqual(callback.callCount, 1); + assert.isTrue(callback.firstCall.calledWithExactly(true)); + }); + + test('with focus change callback finishes ephemeral does not calls focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const finishFocusCallback = this.focusManager.takeEphemeralFocus( + ephemeralElement, + callback, + ); + callback.resetHistory(); + + finishFocusCallback(); + + assert.isFalse(callback.called); + }); + + test('with focus change callback set focus to ephemeral child does not call focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElementChild = document.getElementById( + 'nonTreeElementForEphemeralFocus.child1', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + callback.resetHistory(); + + ephemeralElementChild.focus(); + + // Focusing a child element shouldn't invoke the callback since the + // ephemeral element's tree still holds focus. + assert.isFalse(callback.called); + }); + + test('with focus change callback set focus to non-ephemeral element calls focus change callback', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + + ephemeralElement2.focus(); + + // There should be a second call that indicates focus was lost. + assert.strictEqual(callback.callCount, 2); + assert.isTrue(callback.secondCall.calledWithExactly(false)); + }); + + test('with focus change callback set focus to non-ephemeral element then back calls focus change callback again', function () { + const callback = sinon.fake(); + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + const ephemeralElement = document.getElementById( + 'nonTreeElementForEphemeralFocus', + ); + const ephemeralElementChild = document.getElementById( + 'nonTreeElementForEphemeralFocus.child1', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + this.focusManager.takeEphemeralFocus(ephemeralElement, callback); + ephemeralElement2.focus(); + + ephemeralElementChild.focus(); + + // The latest call should be returning focus. + assert.strictEqual(callback.callCount, 3); + assert.isTrue(callback.thirdCall.calledWithExactly(true)); + }); + + test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + const finishFocusCallback = this.focusManager.takeEphemeralFocus( + ephemeralElement, + (hasFocus) => { + if (!hasFocus) finishFocusCallback(); + }, + ); + + // Force focus away, triggering the callback's automatic returning logic. + ephemeralElement2.focus(); + + // The original focused node should be restored. + const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.strictEqual( + this.focusManager.getFocusedNode(), + this.testFocusableTree2Node1, + ); + assert.strictEqual(activeElems.length, 1); + assert.includesClass( + nodeElem.classList, + FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, nodeElem); + }); + + test('with focus on non-ephemeral element ephemeral ended does not restore to focused node', function () { + this.focusManager.registerTree(this.testFocusableTree2); + this.focusManager.registerTree(this.testFocusableGroup2); + this.focusManager.focusNode(this.testFocusableTree2Node1); + const ephemeralElement = document.getElementById( + 'nonTreeGroupForEphemeralFocus', + ); + const ephemeralElement2 = document.getElementById( + 'nonTreeElementForEphemeralFocus2', + ); + const finishFocusCallback = + this.focusManager.takeEphemeralFocus(ephemeralElement); + // Force focus away, triggering the callback's automatic returning logic. + ephemeralElement2.focus(); + + finishFocusCallback(); + + // The original node should not be focused since the ephemeral element + // lost its own DOM focus while ephemeral focus was active. Instead, the + // newly active element should still hold focus. + const activeElems = Array.from( + document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), + ); + const passiveElems = Array.from( + document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), + ); + assert.isEmpty(activeElems); + assert.strictEqual(passiveElems.length, 1); + assert.includesClass( + this.testFocusableTree2Node1.getFocusableElement().classList, + FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, + ); + assert.strictEqual(document.activeElement, ephemeralElement2); + assert.isFalse(this.focusManager.ephemeralFocusTaken()); + }); }); }); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 208c29955..fea0fb18e 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -94,7 +94,13 @@
Unfocusable element
-
+
+
+
+
From 7ad18f717a0253aabc0d2fb1dd5bb3199ff0bc9b Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Mon, 7 Jul 2025 17:40:58 +0100 Subject: [PATCH 14/17] Revert "fix: Auto close drop-down divs on lost focus (#9175)" (#9204) This reverts commit 4c78c1d4a31fd2474737a3068c11d11881ae954e / PR #9175. --- core/dropdowndiv.ts | 36 +------ core/focus_manager.ts | 88 ++-------------- tests/mocha/dropdowndiv_test.js | 72 +------------ tests/mocha/focus_manager_test.js | 167 ------------------------------ tests/mocha/index.html | 8 +- 5 files changed, 13 insertions(+), 358 deletions(-) diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index 608fe9b5b..ceab467a8 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -213,8 +213,6 @@ export function setColour(backgroundColour: string, borderColour: string) { * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. - * @param autoCloseOnLostFocus Whether the drop-down should automatically hide - * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByBlock( @@ -223,13 +221,11 @@ export function showPositionedByBlock( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, - autoCloseOnLostFocus: boolean = true, ): boolean { return showPositionedByRect( getScaledBboxOfBlock(block), field as Field, manageEphemeralFocus, - autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -249,8 +245,6 @@ export function showPositionedByBlock( * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. Defaults * to true. - * @param autoCloseOnLostFocus Whether the drop-down should automatically hide - * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ export function showPositionedByField( @@ -258,14 +252,12 @@ export function showPositionedByField( opt_onHide?: () => void, opt_secondaryYOffset?: number, manageEphemeralFocus: boolean = true, - autoCloseOnLostFocus: boolean = true, ): boolean { positionToField = true; return showPositionedByRect( getScaledBboxOfField(field as Field), field as Field, manageEphemeralFocus, - autoCloseOnLostFocus, opt_onHide, opt_secondaryYOffset, ); @@ -310,15 +302,12 @@ function getScaledBboxOfField(field: Field): Rect { * according to the drop-down div's lifetime. Note that if a false value is * passed in here then callers should manage ephemeral focus directly * otherwise focus may not properly restore when the widget closes. - * @param autoCloseOnLostFocus Whether the drop-down should automatically hide - * if it loses DOM focus for any reason. * @returns True if the menu rendered below block; false if above. */ function showPositionedByRect( bBox: Rect, field: Field, manageEphemeralFocus: boolean, - autoCloseOnLostFocus: boolean, opt_onHide?: () => void, opt_secondaryYOffset?: number, ): boolean { @@ -346,7 +335,6 @@ function showPositionedByRect( secondaryX, secondaryY, manageEphemeralFocus, - autoCloseOnLostFocus, opt_onHide, ); } @@ -369,8 +357,6 @@ function showPositionedByRect( * @param opt_onHide Optional callback for when the drop-down is hidden. * @param manageEphemeralFocus Whether ephemeral focus should be managed * according to the widget div's lifetime. - * @param autoCloseOnLostFocus Whether the drop-down should automatically hide - * if it loses DOM focus for any reason. * @returns True if the menu rendered at the primary origin point. * @internal */ @@ -382,7 +368,6 @@ export function show( secondaryX: number, secondaryY: number, manageEphemeralFocus: boolean, - autoCloseOnLostFocus: boolean, opt_onHide?: () => void, ): boolean { owner = newOwner as Field; @@ -409,18 +394,7 @@ export function show( // Ephemeral focus must happen after the div is fully visible in order to // ensure that it properly receives focus. if (manageEphemeralFocus) { - const autoCloseCallback = autoCloseOnLostFocus - ? (hasFocus: boolean) => { - // If focus is ever lost, close the drop-down. - if (!hasFocus) { - hide(); - } - } - : null; - returnEphemeralFocus = getFocusManager().takeEphemeralFocus( - div, - autoCloseCallback, - ); + returnEphemeralFocus = getFocusManager().takeEphemeralFocus(div); } return atOrigin; @@ -719,6 +693,7 @@ export function hideWithoutAnimation() { onHide(); onHide = null; } + clearContent(); owner = null; (common.getMainWorkspace() as WorkspaceSvg).markFocused(); @@ -727,13 +702,6 @@ export function hideWithoutAnimation() { returnEphemeralFocus(); returnEphemeralFocus = null; } - - // Content must be cleared after returning ephemeral focus since otherwise it - // may force focus changes which could desynchronize the focus manager and - // make it think the user directed focus away from the drop-down div (which - // will then notify it to not restore focus back to any previously focused - // node). - clearContent(); } /** diff --git a/core/focus_manager.ts b/core/focus_manager.ts index 31453b827..02e059107 100644 --- a/core/focus_manager.ts +++ b/core/focus_manager.ts @@ -17,14 +17,6 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js'; */ export type ReturnEphemeralFocus = () => void; -/** - * Type declaration for an optional callback to observe when an element with - * ephemeral focus has its DOM focus changed before ephemeral focus is returned. - * - * See FocusManager.takeEphemeralFocus for more details. - */ -export type EphemeralFocusChangedInDom = (hasDomFocus: boolean) => void; - /** * Represents an IFocusableTree that has been registered for focus management in * FocusManager. @@ -86,10 +78,7 @@ export class FocusManager { private previouslyFocusedNode: IFocusableNode | null = null; private registeredTrees: Array = []; - private ephemerallyFocusedElement: HTMLElement | SVGElement | null = null; - private ephemeralDomFocusChangedCallback: EphemeralFocusChangedInDom | null = - null; - private ephemerallyFocusedElementCurrentlyHasFocus: boolean = false; + private currentlyHoldsEphemeralFocus: boolean = false; private lockFocusStateChanges: boolean = false; private recentlyLostAllFocus: boolean = false; private isUpdatingFocusedNode: boolean = false; @@ -129,21 +118,6 @@ export class FocusManager { } else { this.defocusCurrentFocusedNode(); } - - const ephemeralFocusElem = this.ephemerallyFocusedElement; - if (ephemeralFocusElem) { - const hadFocus = this.ephemerallyFocusedElementCurrentlyHasFocus; - const hasFocus = - !!element && - element instanceof Node && - ephemeralFocusElem.contains(element); - if (hadFocus !== hasFocus) { - if (this.ephemeralDomFocusChangedCallback) { - this.ephemeralDomFocusChangedCallback(hasFocus); - } - this.ephemerallyFocusedElementCurrentlyHasFocus = hasFocus; - } - } }; // Register root document focus listeners for tracking when focus leaves all @@ -339,7 +313,7 @@ export class FocusManager { */ focusNode(focusableNode: IFocusableNode): void { this.ensureManagerIsUnlocked(); - const mustRestoreUpdatingNode = !this.ephemerallyFocusedElement; + const mustRestoreUpdatingNode = !this.currentlyHoldsEphemeralFocus; if (mustRestoreUpdatingNode) { // Disable state syncing from DOM events since possible calls to focus() // below will loop a call back to focusNode(). @@ -421,7 +395,7 @@ export class FocusManager { this.removeHighlight(nextTreeRoot); } - if (!this.ephemerallyFocusedElement) { + if (!this.currentlyHoldsEphemeralFocus) { // Only change the actively focused node if ephemeral state isn't held. this.activelyFocusNode(nodeToFocus, prevTree ?? null); } @@ -449,50 +423,24 @@ export class FocusManager { * the returned lambda is called. Additionally, only 1 ephemeral focus context * can be active at any given time (attempting to activate more than one * simultaneously will result in an error being thrown). - * - * Important details regarding the onFocusChangedInDom callback: - * - This method will be called initially with a value of 'true' indicating - * that the ephemeral element has been focused, so callers can rely on that, - * if needed, for initialization logic. - * - It's safe to end ephemeral focus in this callback (and is encouraged for - * callers that wish to automatically end ephemeral focus when the user - * directs focus outside of the element). - * - The element AND all of its descendants are tracked for focus. That means - * the callback will ONLY be called with a value of 'false' if focus - * completely leaves the DOM tree for the provided focusable element. - * - It's invalid to return focus on the very first call to the callback, - * however this is expected to be impossible, anyway, since this method - * won't return until after the first call to the callback (thus there will - * be no means to return ephemeral focus). - * - * @param focusableElement The element that should be focused until returned. - * @param onFocusChangedInDom An optional callback which will be notified - * whenever the provided element's focus changes before ephemeral focus is - * returned. See the details above for specifics. - * @returns A ReturnEphemeralFocus that must be called when ephemeral focus - * should end. */ takeEphemeralFocus( focusableElement: HTMLElement | SVGElement, - onFocusChangedInDom: EphemeralFocusChangedInDom | null = null, ): ReturnEphemeralFocus { this.ensureManagerIsUnlocked(); - if (this.ephemerallyFocusedElement) { + if (this.currentlyHoldsEphemeralFocus) { throw Error( `Attempted to take ephemeral focus when it's already held, ` + `with new element: ${focusableElement}.`, ); } - this.ephemerallyFocusedElement = focusableElement; - this.ephemeralDomFocusChangedCallback = onFocusChangedInDom; + this.currentlyHoldsEphemeralFocus = true; if (this.focusedNode) { this.passivelyFocusNode(this.focusedNode, null); } focusableElement.focus(); - this.ephemerallyFocusedElementCurrentlyHasFocus = true; - const focusedNodeAtStart = this.focusedNode; let hasFinishedEphemeralFocus = false; return () => { if (hasFinishedEphemeralFocus) { @@ -502,22 +450,9 @@ export class FocusManager { ); } hasFinishedEphemeralFocus = true; - this.ephemerallyFocusedElement = null; - this.ephemeralDomFocusChangedCallback = null; + this.currentlyHoldsEphemeralFocus = false; - const hadEphemeralFocusAtEnd = - this.ephemerallyFocusedElementCurrentlyHasFocus; - this.ephemerallyFocusedElementCurrentlyHasFocus = false; - - // If the user forced away DOM focus during ephemeral focus, then - // determine whether focus should be restored back to a focusable node - // after ephemeral focus ends. Generally it shouldn't be, but in some - // cases (such as the user focusing an actual focusable node) it then - // should be. - const hasNewFocusedNode = focusedNodeAtStart !== this.focusedNode; - const shouldRestoreToNode = hasNewFocusedNode || hadEphemeralFocusAtEnd; - - if (this.focusedNode && shouldRestoreToNode) { + if (this.focusedNode) { this.activelyFocusNode(this.focusedNode, null); // Even though focus was restored, check if it's lost again. It's @@ -535,11 +470,6 @@ export class FocusManager { this.focusNode(capturedNode); } }, 0); - } else { - // If the ephemeral element lost focus then do not force it back since - // that likely will override the user's own attempt to move focus away - // from the ephemeral experience. - this.defocusCurrentFocusedNode(); } }; } @@ -548,7 +478,7 @@ export class FocusManager { * @returns whether something is currently holding ephemeral focus */ ephemeralFocusTaken(): boolean { - return !!this.ephemerallyFocusedElement; + return this.currentlyHoldsEphemeralFocus; } /** @@ -586,7 +516,7 @@ export class FocusManager { // The current node will likely be defocused while ephemeral focus is held, // but internal manager state shouldn't change since the node should be // restored upon exiting ephemeral focus mode. - if (this.focusedNode && !this.ephemerallyFocusedElement) { + if (this.focusedNode && !this.currentlyHoldsEphemeralFocus) { this.passivelyFocusNode(this.focusedNode, null); this.updateFocusedNode(null); } diff --git a/tests/mocha/dropdowndiv_test.js b/tests/mocha/dropdowndiv_test.js index fac8368a9..fc792fbaf 100644 --- a/tests/mocha/dropdowndiv_test.js +++ b/tests/mocha/dropdowndiv_test.js @@ -155,7 +155,7 @@ suite('DropDownDiv', function () { }); test('Escape dismisses DropDownDiv', function () { let hidden = false; - Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, false, () => { + Blockly.DropDownDiv.show(this, false, 0, 0, 0, 0, false, () => { hidden = true; }); assert.isFalse(hidden); @@ -252,34 +252,6 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); - - test('without auto close on lost focus lost focus does not hide drop-down div', function () { - const block = this.setUpBlockWithField(); - const field = Array.from(block.getFields())[0]; - Blockly.getFocusManager().focusNode(block); - Blockly.DropDownDiv.showPositionedByField(field, null, null, true, false); - - // Focus an element outside of the drop-down. - document.getElementById('nonTreeElementForEphemeralFocus').focus(); - - // Even though the drop-down lost focus, it should still be visible. - const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); - assert.strictEqual(dropDownDivElem.style.opacity, '1'); - }); - - test('with auto close on lost focus lost focus hides drop-down div', function () { - const block = this.setUpBlockWithField(); - const field = Array.from(block.getFields())[0]; - Blockly.getFocusManager().focusNode(block); - Blockly.DropDownDiv.showPositionedByField(field, null, null, true, true); - - // Focus an element outside of the drop-down. - document.getElementById('nonTreeElementForEphemeralFocus').focus(); - - // the drop-down should now be hidden since it lost focus. - const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); - assert.strictEqual(dropDownDivElem.style.opacity, '0'); - }); }); suite('showPositionedByBlock()', function () { @@ -353,48 +325,6 @@ suite('DropDownDiv', function () { assert.strictEqual(Blockly.getFocusManager().getFocusedNode(), block); assert.strictEqual(document.activeElement, dropDownDivElem); }); - - test('without auto close on lost focus lost focus does not hide drop-down div', function () { - const block = this.setUpBlockWithField(); - const field = Array.from(block.getFields())[0]; - Blockly.getFocusManager().focusNode(block); - Blockly.DropDownDiv.showPositionedByBlock( - field, - block, - null, - null, - true, - false, - ); - - // Focus an element outside of the drop-down. - document.getElementById('nonTreeElementForEphemeralFocus').focus(); - - // Even though the drop-down lost focus, it should still be visible. - const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); - assert.strictEqual(dropDownDivElem.style.opacity, '1'); - }); - - test('with auto close on lost focus lost focus hides drop-down div', function () { - const block = this.setUpBlockWithField(); - const field = Array.from(block.getFields())[0]; - Blockly.getFocusManager().focusNode(block); - Blockly.DropDownDiv.showPositionedByBlock( - field, - block, - null, - null, - true, - true, - ); - - // Focus an element outside of the drop-down. - document.getElementById('nonTreeElementForEphemeralFocus').focus(); - - // the drop-down should now be hidden since it lost focus. - const dropDownDivElem = document.querySelector('.blocklyDropDownDiv'); - assert.strictEqual(dropDownDivElem.style.opacity, '0'); - }); }); suite('hideWithoutAnimation()', function () { diff --git a/tests/mocha/focus_manager_test.js b/tests/mocha/focus_manager_test.js index cb4a43652..26dcb8dbe 100644 --- a/tests/mocha/focus_manager_test.js +++ b/tests/mocha/focus_manager_test.js @@ -5975,172 +5975,5 @@ suite('FocusManager', function () { ); assert.strictEqual(document.activeElement, nodeElem); }); - - test('with focus change callback initially calls focus change callback with initial state', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - - this.focusManager.takeEphemeralFocus(ephemeralElement, callback); - - assert.strictEqual(callback.callCount, 1); - assert.isTrue(callback.firstCall.calledWithExactly(true)); - }); - - test('with focus change callback finishes ephemeral does not calls focus change callback again', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - const finishFocusCallback = this.focusManager.takeEphemeralFocus( - ephemeralElement, - callback, - ); - callback.resetHistory(); - - finishFocusCallback(); - - assert.isFalse(callback.called); - }); - - test('with focus change callback set focus to ephemeral child does not call focus change callback again', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - const ephemeralElementChild = document.getElementById( - 'nonTreeElementForEphemeralFocus.child1', - ); - this.focusManager.takeEphemeralFocus(ephemeralElement, callback); - callback.resetHistory(); - - ephemeralElementChild.focus(); - - // Focusing a child element shouldn't invoke the callback since the - // ephemeral element's tree still holds focus. - assert.isFalse(callback.called); - }); - - test('with focus change callback set focus to non-ephemeral element calls focus change callback', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - const ephemeralElement2 = document.getElementById( - 'nonTreeElementForEphemeralFocus2', - ); - this.focusManager.takeEphemeralFocus(ephemeralElement, callback); - - ephemeralElement2.focus(); - - // There should be a second call that indicates focus was lost. - assert.strictEqual(callback.callCount, 2); - assert.isTrue(callback.secondCall.calledWithExactly(false)); - }); - - test('with focus change callback set focus to non-ephemeral element then back calls focus change callback again', function () { - const callback = sinon.fake(); - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - const ephemeralElement = document.getElementById( - 'nonTreeElementForEphemeralFocus', - ); - const ephemeralElementChild = document.getElementById( - 'nonTreeElementForEphemeralFocus.child1', - ); - const ephemeralElement2 = document.getElementById( - 'nonTreeElementForEphemeralFocus2', - ); - this.focusManager.takeEphemeralFocus(ephemeralElement, callback); - ephemeralElement2.focus(); - - ephemeralElementChild.focus(); - - // The latest call should be returning focus. - assert.strictEqual(callback.callCount, 3); - assert.isTrue(callback.thirdCall.calledWithExactly(true)); - }); - - test('with focus change callback set focus to non-ephemeral element with auto return finishes ephemeral', function () { - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - this.focusManager.focusNode(this.testFocusableTree2Node1); - const ephemeralElement = document.getElementById( - 'nonTreeGroupForEphemeralFocus', - ); - const ephemeralElement2 = document.getElementById( - 'nonTreeElementForEphemeralFocus2', - ); - const finishFocusCallback = this.focusManager.takeEphemeralFocus( - ephemeralElement, - (hasFocus) => { - if (!hasFocus) finishFocusCallback(); - }, - ); - - // Force focus away, triggering the callback's automatic returning logic. - ephemeralElement2.focus(); - - // The original focused node should be restored. - const nodeElem = this.testFocusableTree2Node1.getFocusableElement(); - const activeElems = Array.from( - document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), - ); - assert.strictEqual( - this.focusManager.getFocusedNode(), - this.testFocusableTree2Node1, - ); - assert.strictEqual(activeElems.length, 1); - assert.includesClass( - nodeElem.classList, - FocusManager.ACTIVE_FOCUS_NODE_CSS_CLASS_NAME, - ); - assert.strictEqual(document.activeElement, nodeElem); - }); - - test('with focus on non-ephemeral element ephemeral ended does not restore to focused node', function () { - this.focusManager.registerTree(this.testFocusableTree2); - this.focusManager.registerTree(this.testFocusableGroup2); - this.focusManager.focusNode(this.testFocusableTree2Node1); - const ephemeralElement = document.getElementById( - 'nonTreeGroupForEphemeralFocus', - ); - const ephemeralElement2 = document.getElementById( - 'nonTreeElementForEphemeralFocus2', - ); - const finishFocusCallback = - this.focusManager.takeEphemeralFocus(ephemeralElement); - // Force focus away, triggering the callback's automatic returning logic. - ephemeralElement2.focus(); - - finishFocusCallback(); - - // The original node should not be focused since the ephemeral element - // lost its own DOM focus while ephemeral focus was active. Instead, the - // newly active element should still hold focus. - const activeElems = Array.from( - document.querySelectorAll(ACTIVE_FOCUS_NODE_CSS_SELECTOR), - ); - const passiveElems = Array.from( - document.querySelectorAll(PASSIVE_FOCUS_NODE_CSS_SELECTOR), - ); - assert.isEmpty(activeElems); - assert.strictEqual(passiveElems.length, 1); - assert.includesClass( - this.testFocusableTree2Node1.getFocusableElement().classList, - FocusManager.PASSIVE_FOCUS_NODE_CSS_CLASS_NAME, - ); - assert.strictEqual(document.activeElement, ephemeralElement2); - assert.isFalse(this.focusManager.ephemeralFocusTaken()); - }); }); }); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index fea0fb18e..208c29955 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -94,13 +94,7 @@
Unfocusable element
-
-
-
-
+
From efb5a2e7f156f4eefdd66015b43c94681e1edba1 Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Mon, 7 Jul 2025 09:49:38 -0700 Subject: [PATCH 15/17] fix: check for a drag specifically rather than a gesture for shortcuts (#9194) --- core/shortcut_items.ts | 7 +++---- tests/mocha/shortcut_items_test.js | 16 ++++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/core/shortcut_items.ts b/core/shortcut_items.ts index 062d0cb4e..f621f93d3 100644 --- a/core/shortcut_items.ts +++ b/core/shortcut_items.ts @@ -11,7 +11,6 @@ import * as clipboard from './clipboard.js'; import {RenderedWorkspaceComment} from './comments.js'; import * as eventUtils from './events/utils.js'; import {getFocusManager} from './focus_manager.js'; -import {Gesture} from './gesture.js'; import {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.js'; import {isDeletable as isIDeletable} from './interfaces/i_deletable.js'; import {isDraggable} from './interfaces/i_draggable.js'; @@ -67,7 +66,7 @@ export function registerDelete() { focused != null && isIDeletable(focused) && focused.isDeletable() && - !Gesture.inProgress() && + !workspace.isDragging() && // Don't delete the block if a field editor is open !getFocusManager().ephemeralFocusTaken() ); @@ -322,7 +321,7 @@ export function registerUndo() { preconditionFn(workspace) { return ( !workspace.isReadOnly() && - !Gesture.inProgress() && + !workspace.isDragging() && !getFocusManager().ephemeralFocusTaken() ); }, @@ -360,7 +359,7 @@ export function registerRedo() { name: names.REDO, preconditionFn(workspace) { return ( - !Gesture.inProgress() && + !workspace.isDragging() && !workspace.isReadOnly() && !getFocusManager().ephemeralFocusTaken() ); diff --git a/tests/mocha/shortcut_items_test.js b/tests/mocha/shortcut_items_test.js index 4ab83d8e1..eaadef01e 100644 --- a/tests/mocha/shortcut_items_test.js +++ b/tests/mocha/shortcut_items_test.js @@ -434,13 +434,13 @@ suite('Keyboard Shortcut Items', function () { }); }); }); - // Do not undo if a gesture is in progress. - suite('Gesture in progress', function () { + // Do not undo if a drag is in progress. + suite('Drag in progress', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; const keyEvent = testCase[1]; test(testCaseName, function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(keyEvent); sinon.assert.notCalled(this.undoSpy); sinon.assert.notCalled(this.hideChaffSpy); @@ -494,13 +494,13 @@ suite('Keyboard Shortcut Items', function () { }); }); }); - // Do not undo if a gesture is in progress. - suite('Gesture in progress', function () { + // Do not redo if a drag is in progress. + suite('Drag in progress', function () { testCases.forEach(function (testCase) { const testCaseName = testCase[0]; const keyEvent = testCase[1]; test(testCaseName, function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(keyEvent); sinon.assert.notCalled(this.redoSpy); sinon.assert.notCalled(this.hideChaffSpy); @@ -534,8 +534,8 @@ suite('Keyboard Shortcut Items', function () { sinon.assert.calledWith(this.undoSpy, true); sinon.assert.calledOnce(this.hideChaffSpy); }); - test('Not called when a gesture is in progress', function () { - sinon.stub(Blockly.Gesture, 'inProgress').returns(true); + test('Not called when a drag is in progress', function () { + sinon.stub(this.workspace, 'isDragging').returns(true); this.injectionDiv.dispatchEvent(this.ctrlYEvent); sinon.assert.notCalled(this.undoSpy); sinon.assert.notCalled(this.hideChaffSpy); From b741d78b5b3e6394bbd29a84309f99743270d8bf Mon Sep 17 00:00:00 2001 From: Christopher Allen Date: Mon, 7 Jul 2025 17:54:00 +0100 Subject: [PATCH 16/17] refactor(CSS): move box-sizing to core/css.ts (#9201) Apply box-sizing to all of Blockly (and thereby obviate the need to apply it to .blocklyHtmlInput in particular. --- core/toolbox/toolbox.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 31bb2b636..4979fdfa4 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -1172,6 +1172,7 @@ Css.register(` /* Category tree in Toolbox. */ .blocklyToolbox { + box-sizing: border-box; user-select: none; -ms-user-select: none; -webkit-user-select: none; From 7184cb24f28c99a7b74e2f0f6721428c54e312ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:23:25 +0100 Subject: [PATCH 17/17] chore(deps): bump eslint-config-prettier from 10.1.1 to 10.1.5 (#9209) Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 10.1.1 to 10.1.5. - [Release notes](https://github.com/prettier/eslint-config-prettier/releases) - [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/eslint-config-prettier/compare/v10.1.1...v10.1.5) --- updated-dependencies: - dependency-name: eslint-config-prettier dependency-version: 10.1.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28f9b3bd6..d1977538e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4060,14 +4060,16 @@ } }, "node_modules/eslint-config-prettier": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz", - "integrity": "sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw==", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, - "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, "peerDependencies": { "eslint": ">=7.0.0" }