diff --git a/eslint.config.mjs b/eslint.config.mjs index 7aaf740fafa..6f94f6f8577 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -177,6 +177,7 @@ export default defineConfig( 'packages/docs/.docusaurus/**', // Plugins 'packages/plugins/dev-tools/src/index.d.ts', + 'packages/plugins/**/golden/*', ], }, jsdoc.configs['flat/recommended'], diff --git a/package-lock.json b/package-lock.json index 6146a7b36a5..5b82e1e3c48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "packages/plugins/theme-tritanopia", "packages/plugins/block-test", "packages/plugins/dev-tools", + "packages/plugins/field-grid-dropdown", "packages/plugins/block-dynamic-connection", "packages/plugins/block-plus-minus", "packages/plugins/block-shareable-procedures", @@ -28,6 +29,14 @@ "packages/plugins/cross-tab-copy-paste", "packages/plugins/dev-create", "packages/plugins/disable-top-blocks", + "packages/plugins/field-angle", + "packages/plugins/field-bitmap", + "packages/plugins/field-colour", + "packages/plugins/field-colour-hsv-sliders", + "packages/plugins/field-date", + "packages/plugins/field-dependent-dropdown", + "packages/plugins/field-multilineinput", + "packages/plugins/field-slider", "packages/plugins/fixed-edges", "packages/plugins/migration", "packages/plugins/modal", @@ -2113,6 +2122,42 @@ "resolved": "packages/plugins/disable-top-blocks", "link": true }, + "node_modules/@blockly/field-angle": { + "resolved": "packages/plugins/field-angle", + "link": true + }, + "node_modules/@blockly/field-bitmap": { + "resolved": "packages/plugins/field-bitmap", + "link": true + }, + "node_modules/@blockly/field-colour": { + "resolved": "packages/plugins/field-colour", + "link": true + }, + "node_modules/@blockly/field-colour-hsv-sliders": { + "resolved": "packages/plugins/field-colour-hsv-sliders", + "link": true + }, + "node_modules/@blockly/field-date": { + "resolved": "packages/plugins/field-date", + "link": true + }, + "node_modules/@blockly/field-dependent-dropdown": { + "resolved": "packages/plugins/field-dependent-dropdown", + "link": true + }, + "node_modules/@blockly/field-grid-dropdown": { + "resolved": "packages/plugins/field-grid-dropdown", + "link": true + }, + "node_modules/@blockly/field-multilineinput": { + "resolved": "packages/plugins/field-multilineinput", + "link": true + }, + "node_modules/@blockly/field-slider": { + "resolved": "packages/plugins/field-slider", + "link": true + }, "node_modules/@blockly/fixed-edges": { "resolved": "packages/plugins/fixed-edges", "link": true @@ -6648,21 +6693,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -6671,9 +6716,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -6789,13 +6834,13 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -10366,9 +10411,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -10788,13 +10833,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -12286,9 +12331,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", - "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -12922,9 +12967,9 @@ } }, "node_modules/chromium-bidi": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", - "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -13509,6 +13554,31 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concurrently/node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -13522,6 +13592,34 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/concurrently/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -13640,9 +13738,9 @@ } }, "node_modules/conventional-commits-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.0.tgz", - "integrity": "sha512-DPp6hkUjvwIivxbkrTiLXeRswNv1A/4GFA2X6scXma0AMa9632V3TwxmrlkUIEtUktiM3Ln+RrSH2xlP3/jUTw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-7.1.1.tgz", + "integrity": "sha512-B0f42jI++V5Vb7qK+DDw68r0dNxz5hk+RdKUkx2NOi39emc9hsHa3u2M3doF7QQhRFzCrAj7uM90teG+RBTaYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14815,9 +14913,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1638949", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz", - "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==", + "version": "0.0.1653615", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", + "integrity": "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==", "dev": true, "license": "BSD-3-Clause" }, @@ -15527,9 +15625,9 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "63.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.2.2.tgz", - "integrity": "sha512-xCoHKeaRwAhZ+i2ZUY11few2Lmy9qpJLDsY139c4KNJLMSZYoFJ5UMYkDjdTkO9PfkFOKmVO+7i4Yjx+x7VQWQ==", + "version": "63.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.3.1.tgz", + "integrity": "sha512-yVyTPtOY2tA8EjTwUtaM3pFxj9i/3m4/FSPMv5gMnDHSSleCpzIYQLyV8KnVmEIkkPpNhDlkDQ99mTfjxgqLxA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -15771,13 +15869,13 @@ "license": "MIT" }, "node_modules/eslint/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -17451,9 +17549,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, "license": "MIT", "engines": { @@ -18409,9 +18507,9 @@ } }, "node_modules/html-webpack-plugin": { - "version": "5.6.7", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz", - "integrity": "sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw==", + "version": "5.6.8", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.8.tgz", + "integrity": "sha512-MZmKQcTnhEh1SPSyMiEytIeDZDUoBZVorNHivQGXMASHf/BSGGOrKa2xQ5bGx3TCe1n109ecCt+cpww7wwWhKA==", "license": "MIT", "dependencies": { "@types/html-minifier-terser": "^6.0.0", @@ -18428,7 +18526,7 @@ "url": "https://opencollective.com/html-webpack-plugin" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || 1.x || 2.x", "webpack": "^5.20.0" }, "peerDependenciesMeta": { @@ -26653,18 +26751,18 @@ } }, "node_modules/puppeteer-core": { - "version": "25.3.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.3.0.tgz", - "integrity": "sha512-fm+wpUr2oigH1PXZvwgATrM2tYWHMDG8ASzTEe9uukCye4X5Ldx1K5BTHPFKITrIWvQQAQ256d1NpbEveBcKjA==", + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.4.0.tgz", + "integrity": "sha512-K1plkLOdeoUnGeT1OvdqF3qxl33v+Ra/uH5VyPEhXdMcpvGiEskHzxxEU3fgpccJpJLIipB/rPUsvkZRWeKqOA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@puppeteer/browsers": "3.0.6", - "chromium-bidi": "16.0.1", - "devtools-protocol": "0.0.1638949", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", - "ws": "^8.21.0" + "ws": "^8.21.1" }, "engines": { "node": ">=22.12.0" @@ -27825,12 +27923,12 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -29519,9 +29617,9 @@ } }, "node_modules/thingies": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", - "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", "license": "MIT", "engines": { "node": ">=10.18" @@ -31210,9 +31308,9 @@ } }, "node_modules/webpack": { - "version": "5.109.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.0.tgz", - "integrity": "sha512-vomrngskVVXEZF9sMZfYAd4pXZUnfaWdJGlF+BTNF+gJBCKYCQBnOeVPlrh39Ewl7nlCsirDplMy6o5g9xJHBg==", + "version": "5.109.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.1.tgz", + "integrity": "sha512-Q4XQscWSLNQSaMFsIWUZ+IVpvwLqD+MvjIuSQC1hG8m1ZMK78VAsNMhTjD3icJelypp6aM5Hq8BZNEX3sc1PJw==", "license": "MIT", "dependencies": { "@types/estree": "^1.0.8", @@ -32012,16 +32110,16 @@ } }, "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", "dev": true, "license": "MIT", "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", + "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" }, @@ -32074,26 +32172,18 @@ "node": ">=8" } }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -32326,13 +32416,13 @@ } }, "packages/blockly/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -32418,7 +32508,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/block-plus-minus": { @@ -32433,7 +32523,7 @@ "sinon": "^22.0.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/block-shareable-procedures": { @@ -32450,7 +32540,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/block-test": { @@ -32461,7 +32551,7 @@ "@blockly/dev-scripts": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/content-highlight": { @@ -32474,7 +32564,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/continuous-toolbox": { @@ -32487,7 +32577,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/cross-tab-copy-paste": { @@ -32499,7 +32589,7 @@ "@blockly/dev-tools": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/dev-create": { @@ -32607,7 +32697,7 @@ "@types/dat.gui": "^0.7.13" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/disable-top-blocks": { @@ -32619,7 +32709,176 @@ "@blockly/dev-tools": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-angle": { + "name": "@blockly/field-angle", + "version": "13.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-bitmap": { + "name": "@blockly/field-bitmap", + "version": "13.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "mocha": "^11.7.6", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-colour": { + "name": "@blockly/field-colour", + "version": "13.1.0", + "license": "Apache-2.0", + "dependencies": { + "@blockly/field-grid-dropdown": "^13.1.0" + }, + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "@typescript-eslint/parser": "^8.61.1", + "chai": "^6.2.2", + "jsdom": "^29.1.1", + "jsdom-global": "^3.0.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-colour-hsv-sliders": { + "name": "@blockly/field-colour-hsv-sliders", + "version": "13.1.0", + "license": "Apache-2.0", + "dependencies": { + "@blockly/field-colour": "^6.0.12" + }, + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "jsdom": "^29.1.1", + "jsdom-global": "^3.0.2", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-colour-hsv-sliders/node_modules/@blockly/field-colour": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@blockly/field-colour/-/field-colour-6.0.12.tgz", + "integrity": "sha512-sZUlMtn1Bnj3/inayhZAMJ1ZUxFfR8eISBWPgOvWMBlnUIeCWXJDc76a6JVEcZhnjHFjDI+ihOXYdrp5cedShA==", + "license": "Apache-2.0", + "dependencies": { + "@blockly/field-grid-dropdown": "^6.0.10" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" + } + }, + "packages/plugins/field-colour-hsv-sliders/node_modules/@blockly/field-grid-dropdown": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@blockly/field-grid-dropdown/-/field-grid-dropdown-6.0.10.tgz", + "integrity": "sha512-b54xLznsFb5OyAfpFYCFX52M7vRc9dITbLzTBIsETuXfVX8R4TFj3z0+QbJ1znlqNhr5+dgrfDfkUEm8+8zbxg==", + "license": "Apache 2.0", + "engines": { + "node": ">=8.17.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" + } + }, + "packages/plugins/field-date": { + "name": "@blockly/field-date", + "version": "13.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-dependent-dropdown": { + "name": "@blockly/field-dependent-dropdown", + "version": "13.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-grid-dropdown": { + "name": "@blockly/field-grid-dropdown", + "version": "13.1.0", + "license": "Apache 2.0", + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-multilineinput": { + "name": "@blockly/field-multilineinput", + "version": "13.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + } + }, + "packages/plugins/field-slider": { + "name": "@blockly/field-slider", + "version": "13.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" } }, "packages/plugins/fixed-edges": { @@ -32631,7 +32890,7 @@ "@blockly/dev-tools": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/migration": { @@ -32689,7 +32948,7 @@ "sinon": "22.0.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/modal/node_modules/diff": { @@ -32729,7 +32988,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/shadow-block-converter": { @@ -32746,7 +33005,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/strict-connection-checker": { @@ -32759,7 +33018,7 @@ "chai": "^6.2.2" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/suggested-blocks": { @@ -32773,7 +33032,7 @@ "sinon": "^22.0.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/theme-dark": { @@ -32784,7 +33043,7 @@ "@blockly/dev-scripts": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/theme-deuteranopia": { @@ -32795,7 +33054,7 @@ "@blockly/dev-scripts": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/theme-hackermode": { @@ -32807,7 +33066,7 @@ "@blockly/dev-tools": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/theme-highcontrast": { @@ -32818,7 +33077,7 @@ "@blockly/dev-scripts": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/theme-modern": { @@ -32829,7 +33088,7 @@ "@blockly/dev-scripts": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/theme-tritanopia": { @@ -32840,7 +33099,7 @@ "@blockly/dev-scripts": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/toolbox-search": { @@ -32854,7 +33113,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/typed-variable-modal": { @@ -32873,7 +33132,7 @@ "sinon": "^22.0.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/workspace-backpack": { @@ -32886,7 +33145,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/workspace-minimap": { @@ -32900,7 +33159,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/workspace-search": { @@ -32916,7 +33175,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } }, "packages/plugins/zoom-to-fit": { @@ -32929,7 +33188,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" } } } diff --git a/package.json b/package.json index 5a48316d58d..088f2e2eecf 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "packages/plugins/theme-tritanopia", "packages/plugins/block-test", "packages/plugins/dev-tools", + "packages/plugins/field-grid-dropdown", "packages/plugins/block-dynamic-connection", "packages/plugins/block-plus-minus", "packages/plugins/block-shareable-procedures", @@ -35,6 +36,14 @@ "packages/plugins/cross-tab-copy-paste", "packages/plugins/dev-create", "packages/plugins/disable-top-blocks", + "packages/plugins/field-angle", + "packages/plugins/field-bitmap", + "packages/plugins/field-colour", + "packages/plugins/field-colour-hsv-sliders", + "packages/plugins/field-date", + "packages/plugins/field-dependent-dropdown", + "packages/plugins/field-multilineinput", + "packages/plugins/field-slider", "packages/plugins/fixed-edges", "packages/plugins/migration", "packages/plugins/modal", diff --git a/packages/plugins/block-dynamic-connection/package.json b/packages/plugins/block-dynamic-connection/package.json index 7807a99e220..ec4f37f387a 100644 --- a/packages/plugins/block-dynamic-connection/package.json +++ b/packages/plugins/block-dynamic-connection/package.json @@ -47,7 +47,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/block-plus-minus/package.json b/packages/plugins/block-plus-minus/package.json index f44c5dc555c..b0b538b531f 100644 --- a/packages/plugins/block-plus-minus/package.json +++ b/packages/plugins/block-plus-minus/package.json @@ -46,7 +46,7 @@ "sinon": "^22.0.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/block-shareable-procedures/package.json b/packages/plugins/block-shareable-procedures/package.json index 4a70a5da988..9c06f26217a 100644 --- a/packages/plugins/block-shareable-procedures/package.json +++ b/packages/plugins/block-shareable-procedures/package.json @@ -50,7 +50,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/block-test/package.json b/packages/plugins/block-test/package.json index cb2203debd6..76da4052dec 100644 --- a/packages/plugins/block-test/package.json +++ b/packages/plugins/block-test/package.json @@ -42,7 +42,7 @@ "@blockly/dev-scripts": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/content-highlight/package.json b/packages/plugins/content-highlight/package.json index 76084018924..7d9d5884c9b 100644 --- a/packages/plugins/content-highlight/package.json +++ b/packages/plugins/content-highlight/package.json @@ -48,7 +48,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/continuous-toolbox/package.json b/packages/plugins/continuous-toolbox/package.json index 6d4efe0f479..f0c2fe3d991 100644 --- a/packages/plugins/continuous-toolbox/package.json +++ b/packages/plugins/continuous-toolbox/package.json @@ -44,7 +44,7 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/cross-tab-copy-paste/package.json b/packages/plugins/cross-tab-copy-paste/package.json index daa596d8991..dd9629517c5 100644 --- a/packages/plugins/cross-tab-copy-paste/package.json +++ b/packages/plugins/cross-tab-copy-paste/package.json @@ -43,7 +43,7 @@ "@blockly/dev-tools": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/dev-tools/package.json b/packages/plugins/dev-tools/package.json index 126357dbdce..5810b0d802f 100644 --- a/packages/plugins/dev-tools/package.json +++ b/packages/plugins/dev-tools/package.json @@ -55,7 +55,7 @@ "@types/dat.gui": "^0.7.13" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/disable-top-blocks/package.json b/packages/plugins/disable-top-blocks/package.json index b6a444258da..6e350aa9fbd 100644 --- a/packages/plugins/disable-top-blocks/package.json +++ b/packages/plugins/disable-top-blocks/package.json @@ -42,7 +42,7 @@ "@blockly/dev-tools": "^13.1.0" }, "peerDependencies": { - "blockly": "^13.1.1" + "blockly": "^13.2.0" }, "publishConfig": { "access": "public" diff --git a/packages/plugins/field-angle/CHANGELOG.md b/packages/plugins/field-angle/CHANGELOG.md new file mode 100644 index 00000000000..96cb91e9822 --- /dev/null +++ b/packages/plugins/field-angle/CHANGELOG.md @@ -0,0 +1,501 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [13.1.0](https://github.com/RaspberryPiFoundation/blockly-samples/compare/v13.0.0...v13.1.0) (2026-06-30) + +### ⚠ BREAKING CHANGES + +* make field-multilineinput keyboard navigable and accessible (#2729) +* Bump Blockly dependency to v13 (#2704) + +### Features + +* make field-multilineinput keyboard navigable and accessible ([#2729](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2729)) + +### Bug Fixes + +* Improve accessibility of `FieldAngle` ([#2697](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2697)) + +### Miscellaneous Chores + +* Bump Blockly dependency to v13 ([#2704](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2704)) + + +## [6.0.9](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-angle@6.0.8...@blockly/field-angle@6.0.9) (2026-04-09) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [6.0.8](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-angle@6.0.7...@blockly/field-angle@6.0.8) (2026-01-15) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [6.0.7](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-angle@6.0.6...@blockly/field-angle@6.0.7) (2026-01-05) + + +### Bug Fixes + +* update plugin URLs to RPF ([#2665](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2665)) ([6c34039](https://github.com/RaspberryPiFoundation/blockly-samples/commit/6c3403960b8070eb8e576bd5702bdb53a09ac475)) + + + +## [6.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-angle@6.0.5...@blockly/field-angle@6.0.6) (2026-01-05) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [6.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-angle@6.0.4...@blockly/field-angle@6.0.5) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [6.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-angle@6.0.3...@blockly/field-angle@6.0.4) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [6.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-angle@6.0.2...@blockly/field-angle@6.0.3) (2025-10-02) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [6.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-angle@6.0.1...@blockly/field-angle@6.0.2) (2025-07-17) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [6.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-angle@6.0.0...@blockly/field-angle@6.0.1) (2025-05-22) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [6.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.16...@blockly/field-angle@6.0.0) (2025-05-16) + + +### ⚠ BREAKING CHANGES + +* update all plugins to blockly v12 (#2538) + +### Features + +* update all plugins to blockly v12 ([#2538](https://github.com/google/blockly-samples/issues/2538)) ([420ba31](https://github.com/google/blockly-samples/commit/420ba3124b260b2c06e8e1d61be44e870780d95f)) + + + +## [5.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.15...@blockly/field-angle@5.0.16) (2025-05-16) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.14...@blockly/field-angle@5.0.15) (2025-05-15) + + +### Bug Fixes + +* Fix ephemeral focus being taken twice ([#2521](https://github.com/google/blockly-samples/issues/2521)) ([29b5a0d](https://github.com/google/blockly-samples/commit/29b5a0d896e7e6f7eb83867bd5b2577bf808e839)), closes [#2514](https://github.com/google/blockly-samples/issues/2514) [#2515](https://github.com/google/blockly-samples/issues/2515) [#2514](https://github.com/google/blockly-samples/issues/2514) [#2515](https://github.com/google/blockly-samples/issues/2515) [#2527](https://github.com/google/blockly-samples/issues/2527) + + + + + +## [5.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.13...@blockly/field-angle@5.0.14) (2025-02-13) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.12...@blockly/field-angle@5.0.13) (2024-12-19) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.11...@blockly/field-angle@5.0.12) (2024-12-03) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.10...@blockly/field-angle@5.0.11) (2024-11-07) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.9...@blockly/field-angle@5.0.10) (2024-09-26) + + +### Bug Fixes + +* **field-*:** Remove unneeded `fieldRegistry.unregister` calls ([#2454](https://github.com/google/blockly-samples/issues/2454)) ([b3ba30e](https://github.com/google/blockly-samples/commit/b3ba30e23dddf0bd98c266659aa229ba6ba685b0)), closes [#2453](https://github.com/google/blockly-samples/issues/2453) + + + + + +## [5.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.8...@blockly/field-angle@5.0.9) (2024-08-29) + + +### Bug Fixes + +* predeploy plugins ([#2449](https://github.com/google/blockly-samples/issues/2449)) ([6b36d8b](https://github.com/google/blockly-samples/commit/6b36d8b344a969f79d89bbc7dcee29ae554759f9)) + + + + + +## [5.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.7...@blockly/field-angle@5.0.8) (2024-08-22) + + +### Bug Fixes + +* lerna v8 breaking our whole shebangle ([#2446](https://github.com/google/blockly-samples/issues/2446)) ([b8b4c21](https://github.com/google/blockly-samples/commit/b8b4c21d4eaf81f527336ae46f6679ff99ac23c3)) + + + + + +## [5.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.6...@blockly/field-angle@5.0.7) (2024-08-08) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.5...@blockly/field-angle@5.0.6) (2024-08-01) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.4...@blockly/field-angle@5.0.5) (2024-07-25) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.3...@blockly/field-angle@5.0.4) (2024-07-11) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.2...@blockly/field-angle@5.0.3) (2024-07-04) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.1...@blockly/field-angle@5.0.2) (2024-06-06) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [5.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-angle@5.0.0...@blockly/field-angle@5.0.1) (2024-05-30) + + +### Bug Fixes + +* multiple blockly instances ([#2375](https://github.com/google/blockly-samples/issues/2375)) ([b231e59](https://github.com/google/blockly-samples/commit/b231e598f2f5f5b0abbfd01d981e35572ad50a26)) + + + +## [5.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-angle@4.0.4...@blockly/field-angle@5.0.0) (2024-05-21) + + +### ⚠ BREAKING CHANGES + +* update all plugins to v11 of blockly + +### Features + +* update all plugins to v11 of blockly ([5fcd3e7](https://github.com/google/blockly-samples/commit/5fcd3e7d53eaadffe9bda9a378b404d34b2f8be2)) +* upgrade ts version, fix field validators ([b2d2191](https://github.com/google/blockly-samples/commit/b2d2191ff11b30347b33f95acf6e58f7ce54d004)) + + +### Bug Fixes + +* Call blocks handle both manual disabling and disabled defs ([#2334](https://github.com/google/blockly-samples/issues/2334)) ([5eade55](https://github.com/google/blockly-samples/commit/5eade55779c4022d14ad4472ff32c93c78199887)) +* remove some uses of generateFieldTextBlocks ([#2357](https://github.com/google/blockly-samples/issues/2357)) ([53b3fa4](https://github.com/google/blockly-samples/commit/53b3fa4604aabb1fc5240d41c8d1d389d4b15301)) + + + +## [4.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-angle@4.0.3...@blockly/field-angle@4.0.4) (2024-05-16) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [4.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-angle@4.0.2...@blockly/field-angle@4.0.3) (2024-05-09) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [4.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-angle@4.0.1...@blockly/field-angle@4.0.2) (2024-04-25) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [4.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-angle@4.0.0...@blockly/field-angle@4.0.1) (2024-04-11) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [4.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.13...@blockly/field-angle@4.0.0) (2024-04-04) + + +### ⚠ BREAKING CHANGES + +* The multiline text input field no longer registers itself on load. The developer must either manually register the field or install blocks, which will install the field. This is part of a move to have no side effects in field and block definitions, so that tree-shaking can remove unwanted fields and blocks. +* The angle field no longer registers itself on load. The developer must manually register the field. This is part of a move to have no side effects in field and block definitions, so that tree-shaking can remove unwanted fields and blocks. + +### Features + +* add block definitions to fields ([#2299](https://github.com/google/blockly-samples/issues/2299)) ([c197af6](https://github.com/google/blockly-samples/commit/c197af6c67bc989bbdb4e83a62abc08167bcbbfe)), closes [#2162](https://github.com/google/blockly-samples/issues/2162) [#2202](https://github.com/google/blockly-samples/issues/2202) [#2221](https://github.com/google/blockly-samples/issues/2221) [#2211](https://github.com/google/blockly-samples/issues/2211) [#2220](https://github.com/google/blockly-samples/issues/2220) [#2232](https://github.com/google/blockly-samples/issues/2232) [#2233](https://github.com/google/blockly-samples/issues/2233) [#2296](https://github.com/google/blockly-samples/issues/2296) + + + +## [3.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.12...@blockly/field-angle@3.0.13) (2024-03-28) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.11...@blockly/field-angle@3.0.12) (2024-02-08) + + +### Bug Fixes + +* Display rounded number in field angle. ([#2191](https://github.com/google/blockly-samples/issues/2191)) ([68d60b7](https://github.com/google/blockly-samples/commit/68d60b7868f0a89c09afd16d64e7f2f16a719d7f)) + + + +## [3.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.10...@blockly/field-angle@3.0.11) (2023-12-14) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.9...@blockly/field-angle@3.0.10) (2023-12-07) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.8...@blockly/field-angle@3.0.9) (2023-11-09) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.7...@blockly/field-angle@3.0.8) (2023-11-02) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.6...@blockly/field-angle@3.0.7) (2023-10-30) + + +### Bug Fixes + +* **packaging:** Don't advertise non-existent ESM entrypoints ([#2022](https://github.com/google/blockly-samples/issues/2022)) ([d019198](https://github.com/google/blockly-samples/commit/d0191984399b784e2928b8fb4c58257bfa857655)), closes [#1877](https://github.com/google/blockly-samples/issues/1877) +* publish type for field angle ([#1956](https://github.com/google/blockly-samples/issues/1956)) ([f0e8dbf](https://github.com/google/blockly-samples/commit/f0e8dbfacfe1a7207380c83a347603a3dd845d58)) +* update tsconfigs to publish types accurately ([df4b084](https://github.com/google/blockly-samples/commit/df4b0844af712f5025a2ec842458b828f3147676)) +* use includes rather than excludes ([40efc25](https://github.com/google/blockly-samples/commit/40efc255329e3ca476ccc247b95a2d05dd77b45e)) + + + +## [3.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.5...@blockly/field-angle@3.0.6) (2023-09-21) + + +### Bug Fixes + +* Fire intermediate events instead of change events while dragging angle field ([#1933](https://github.com/google/blockly-samples/issues/1933)) ([dc79b14](https://github.com/google/blockly-samples/commit/dc79b14e4bc402e76cff960a4d72d4ac3a4365a5)) + + + +## [3.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.4...@blockly/field-angle@3.0.5) (2023-09-14) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.3...@blockly/field-angle@3.0.4) (2023-08-31) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.2...@blockly/field-angle@3.0.3) (2023-08-17) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.1...@blockly/field-angle@3.0.2) (2023-07-24) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-angle@3.0.0...@blockly/field-angle@3.0.1) (2023-07-20) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [3.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-angle@2.0.1...@blockly/field-angle@3.0.0) (2023-07-13) + + +### ⚠ BREAKING CHANGES + +* updated `plugins/field-date` to TypeScript (#1705) + +### Features + +* updated `plugins/field-date` to TypeScript ([#1705](https://github.com/google/blockly-samples/issues/1705)) ([e5531ff](https://github.com/google/blockly-samples/commit/e5531fffe188ee361a16fe48ed126b34e51a8d30)) + + + +## [2.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-angle@2.0.0...@blockly/field-angle@2.0.1) (2023-07-06) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [2.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-angle@1.2.2...@blockly/field-angle@2.0.0) (2023-06-28) + + +### ⚠ BREAKING CHANGES + +* update all plugins to use blockly-v10.0.0 (#1744) + +### Features + +* update all plugins to use blockly-v10.0.0 ([#1744](https://github.com/google/blockly-samples/issues/1744)) ([6cc88cb](https://github.com/google/blockly-samples/commit/6cc88cbef39d4ad664a668d3d46eb29ba7292f9c)) + + + +## [1.2.2](https://github.com/google/blockly-samples/compare/@blockly/field-angle@1.2.1...@blockly/field-angle@1.2.2) (2023-06-22) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [1.2.1](https://github.com/google/blockly-samples/compare/@blockly/field-angle@1.2.0...@blockly/field-angle@1.2.1) (2023-06-15) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## [1.2.0](https://github.com/google/blockly-samples/compare/@blockly/field-angle@1.1.1...@blockly/field-angle@1.2.0) (2023-05-25) + + +### Features + +* Revise fields ([#1700](https://github.com/google/blockly-samples/issues/1700)) ([c326f7b](https://github.com/google/blockly-samples/commit/c326f7b1ef54fe45387046c693ebd5afe2b2c571)) + + + +## [1.1.1](https://github.com/google/blockly-samples/compare/@blockly/field-angle@1.1.0...@blockly/field-angle@1.1.1) (2023-05-18) + +**Note:** Version bump only for package @blockly/field-angle + + + + + +## 1.1.0 (2023-05-11) + + +### Features + +* Add angle, colour, and multiline fields. (Rebase of [#1674](https://github.com/google/blockly-samples/issues/1674)) ([#1693](https://github.com/google/blockly-samples/issues/1693)) ([1b447f4](https://github.com/google/blockly-samples/commit/1b447f41d6293f14e846b8a4d82d289a5637a99a)) diff --git a/packages/plugins/field-angle/README.md b/packages/plugins/field-angle/README.md new file mode 100644 index 00000000000..d9f0b9afdf8 --- /dev/null +++ b/packages/plugins/field-angle/README.md @@ -0,0 +1,112 @@ +# @blockly/field-angle [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +A [Blockly](https://www.npmjs.com/package/blockly) angle field. + +## Installation + +### Yarn + +``` +yarn add @blockly/field-angle +``` + +### npm + +``` +npm install @blockly/field-angle --save +``` + +## Usage + +### Installation + +You must register this field with Blockly. You can do this by calling +`registerFieldAngle` before instantiating your blocks. If another field is +registered under the same name, this field will overwrite it. + +### Parameters + +This field accepts up to 9 parameters, in addition to the 4 accepted by the +[number field][number-field]: + +- "value" to specify the default value of the angle field. Defaults to 0. +- "mode" to specify the basic setup of the angle field. Either Mode.COMPASS + or Mode.PROTRACTOR. COMPASS specifies "clockwise" should be "true", and the + "offset" should be "90". PROTRACTOR specifies that "clockwise" should be + "false" and the "offset" should be 0. These settings can be overridden by + the following options. Defaults to PROTRACTOR. +- "clockwise" to specify whether the value of the angle field should increase + in the clockwise direction (if true) or in the counter-clockwise direction + (if false). Defaults to false. +- "offset" to specify where the minimum/maximum displayed value of the angle + field should be. The offset is in degree units that consider the circle to + be 360 degrees. A 0 offset (or multiple of 360) specifies the right side of + the circle as the minimum/maximum value. More positive values rotate + clockwise and more negative values rotate counter clockwise (regardless of + the "clockwise" setting). Defaults to 0. +- "displayMin" to specify the minimum displayed value of the angle field. The + minimum displayed value may not actually be a selectable value. For example, + you may have a full 0-360 degree circle ("displayMin" of 0), but only be + able to select angle values from 90-270. Defaults to 0. +- "displayMax" to specify the maximum displayed value of the angle field. The + maximum displayed value may not actually be a selectable value. For example, + you may have a full 0-360 degree circle ("displayMax" of 360), but only be + able to select angle values from 90-270. Defaults to 360. +- "minorTick" to specify the distance between small tick marks on the angle + picker. The minorTick is in units from your displayMin-displayMax. The ticks + start at your "min" rounded up to a multiple of your "minorTick". The + ticks end at you "max" rounded down to a multiple of your "minorTick". + Defaults to 15. +- "majorTick" to specify the distance between big tick marks on the angle + picker. The majorTick is in units from your displayMin-displayMax. The ticks + start at your "min" rounded up to a multiple of your "majorTick". The + ticks end at you "max" rounded down to a multiple of your "majorTick". + Defaults to 45. +- "symbol" to specify the unit symbol to append to your number. Defaults to °. + If this is used to specify "radians" or similar, + `Blockly.Msg['ARIA_LABEL_FIELD_ANGLE']` should be overridden accordingly to + ensure that the screenreader description remains consistent. + +### JavaScript + +```js +import * as Blockly from 'blockly'; +import {registerFieldAngle} from '@blockly/field-angle'; + +registerFieldAngle(); +Blockly.Blocks['test_field_angle'] = { + init: function () { + this.appendDummyInput() + .appendField('angle: ') + .appendField(new FieldAngle(90), 'FIELDNAME'); + }, +}; +``` + +### JSON + +```js +import * as Blockly from 'blockly'; +import {registerFieldAngle} from '@blockly/field-angle'; + +registerFieldAngle(); +Blockly.defineBlocksWithJsonArray([ + { + type: 'test_field_angle', + message0: 'angle: %1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + value: 50, + }, + ], + }, +]); +``` + +## License + +Apache 2.0 + +[number-field]: https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/number#creation diff --git a/packages/plugins/field-angle/package.json b/packages/plugins/field-angle/package.json new file mode 100644 index 00000000000..4b90f43aa12 --- /dev/null +++ b/packages/plugins/field-angle/package.json @@ -0,0 +1,55 @@ +{ + "name": "@blockly/field-angle", + "version": "13.1.0", + "description": "A Blockly angle field.", + "scripts": { + "audit:fix": "blockly-scripts auditFix", + "build": "blockly-scripts build", + "clean": "blockly-scripts clean", + "lint": "eslint .", + "predeploy": "blockly-scripts predeploy", + "prepublishOnly": "npm run clean && npm run build", + "start": "blockly-scripts start", + "test": "blockly-scripts test" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.js", + "author": "Blockly Team", + "keywords": [ + "blockly", + "field", + "angle" + ], + "homepage": "https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/field-angle#readme", + "bugs": { + "url": "https://github.com/RaspberryPiFoundation/blockly/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaspberryPiFoundation/blockly.git", + "directory": "packages/plugins/field-angle" + }, + "license": "Apache-2.0", + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src" + ], + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/field-angle/readme-media/clockwise_false.gif b/packages/plugins/field-angle/readme-media/clockwise_false.gif new file mode 100644 index 00000000000..daa54b7fa95 Binary files /dev/null and b/packages/plugins/field-angle/readme-media/clockwise_false.gif differ diff --git a/packages/plugins/field-angle/readme-media/clockwise_true.gif b/packages/plugins/field-angle/readme-media/clockwise_true.gif new file mode 100644 index 00000000000..58e103c1317 Binary files /dev/null and b/packages/plugins/field-angle/readme-media/clockwise_true.gif differ diff --git a/packages/plugins/field-angle/readme-media/collapsed.png b/packages/plugins/field-angle/readme-media/collapsed.png new file mode 100644 index 00000000000..a84dae0d298 Binary files /dev/null and b/packages/plugins/field-angle/readme-media/collapsed.png differ diff --git a/packages/plugins/field-angle/readme-media/compass.gif b/packages/plugins/field-angle/readme-media/compass.gif new file mode 100644 index 00000000000..ac5c1d44553 Binary files /dev/null and b/packages/plugins/field-angle/readme-media/compass.gif differ diff --git a/packages/plugins/field-angle/readme-media/offset_right.png b/packages/plugins/field-angle/readme-media/offset_right.png new file mode 100644 index 00000000000..04c97e1e5c1 Binary files /dev/null and b/packages/plugins/field-angle/readme-media/offset_right.png differ diff --git a/packages/plugins/field-angle/readme-media/offset_top.png b/packages/plugins/field-angle/readme-media/offset_top.png new file mode 100644 index 00000000000..66c62b4145a Binary files /dev/null and b/packages/plugins/field-angle/readme-media/offset_top.png differ diff --git a/packages/plugins/field-angle/readme-media/on_block.png b/packages/plugins/field-angle/readme-media/on_block.png new file mode 100644 index 00000000000..03c2c880c5b Binary files /dev/null and b/packages/plugins/field-angle/readme-media/on_block.png differ diff --git a/packages/plugins/field-angle/readme-media/protractor.gif b/packages/plugins/field-angle/readme-media/protractor.gif new file mode 100644 index 00000000000..fdd032785e9 Binary files /dev/null and b/packages/plugins/field-angle/readme-media/protractor.gif differ diff --git a/packages/plugins/field-angle/readme-media/round_70.gif b/packages/plugins/field-angle/readme-media/round_70.gif new file mode 100644 index 00000000000..430c55be4b6 Binary files /dev/null and b/packages/plugins/field-angle/readme-media/round_70.gif differ diff --git a/packages/plugins/field-angle/readme-media/validator.gif b/packages/plugins/field-angle/readme-media/validator.gif new file mode 100644 index 00000000000..4257c56eafa Binary files /dev/null and b/packages/plugins/field-angle/readme-media/validator.gif differ diff --git a/packages/plugins/field-angle/readme-media/with_editor.png b/packages/plugins/field-angle/readme-media/with_editor.png new file mode 100644 index 00000000000..afe936344bc Binary files /dev/null and b/packages/plugins/field-angle/readme-media/with_editor.png differ diff --git a/packages/plugins/field-angle/readme-media/wrap.gif b/packages/plugins/field-angle/readme-media/wrap.gif new file mode 100644 index 00000000000..146f7b29e3e Binary files /dev/null and b/packages/plugins/field-angle/readme-media/wrap.gif differ diff --git a/packages/plugins/field-angle/src/field_angle.ts b/packages/plugins/field-angle/src/field_angle.ts new file mode 100644 index 00000000000..4c13e2fdd66 --- /dev/null +++ b/packages/plugins/field-angle/src/field_angle.ts @@ -0,0 +1,800 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Angle input field. + */ + +import * as Blockly from 'blockly/core'; + +/** + * Class for an editable angle field. + */ +export class FieldAngle extends Blockly.FieldNumber { + /** Half the width of protractor image. */ + static readonly HALF = 100 / 2; + + /** + * Radius of protractor circle. Slightly smaller than protractor size since + * otherwise SVG crops off half the border at the edges. + */ + static readonly RADIUS: number = FieldAngle.HALF - 1; + + static readonly DEFAULT_PRECISION = 15; + static readonly DEFAULT_MIN = 0; + static readonly DEFAULT_MAX = 360; + + /** + * Whether the angle should increase as the angle picker is moved clockwise + * (true) or counterclockwise (false). + */ + private clockwise = false; + + /** + * The angle (in degrees) at which displayMin is pointed. Always in the + * counterclockwise direction, regardless of the field's clockwise property. + * Usually either 0 (0 = right) or 90 (0 = up). + */ + private offset = 0; + + /** + * Smallest value displayed on the gauge. Usually 0 or -180. + */ + private displayMin = 0; + + /** + * Largest value displayed on the gauge. Usually 360 or 180. + */ + private displayMax = 360; + + /** + * Distance between minor tick marks on dial. Zero to disable. + * If displayMin/Max span 360, then majorTick would usually be 15. + * May not be negative. + */ + private minorTick = 15; + + /** + * Distance between major tick marks on dial. Zero to disable. + * If displayMin/Max span 360, then majorTick would usually be 45. + * May not be negative. + */ + private majorTick = 45; + + /** + * Unit symbol to append to the number when not being edited. + */ + private symbol = '°'; + + /** + * Array holding info needed to unbind events. + * Used for disposing. + * Ex: [[node, name, func], [node, name, func]]. + */ + private boundEvents: Blockly.browserEvents.Data[] = []; + + /** Dynamic red line pointing at the value's angle. */ + private line: SVGLineElement | null = null; + + /** Dynamic pink area extending from 0 to the value's angle. */ + private gauge: SVGPathElement | null = null; + + /** The degree symbol for this field. */ + protected symbolElement: SVGTSpanElement | null = null; + + /** + * @param value The initial value of the field. Should cast to a number. + * Defaults to 0. Also accepts Field.SKIP_SETUP if you wish to skip setup + * (only used by subclasses that want to handle configuration and setting + * the field value after their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a number & returns a validated number, or null + * to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/angle#creation} + * for a list of properties this parameter supports. + */ + constructor( + value?: string | number | typeof Blockly.Field.SKIP_SETUP, + validator?: FieldAngleValidator, + config?: FieldAngleConfig, + ) { + super(Blockly.Field.SKIP_SETUP); + + if (value === Blockly.Field.SKIP_SETUP) return; + if (config) { + this.configure_(config); + if (config.min === undefined || config.min === null) { + this.setMin(FieldAngle.DEFAULT_MIN); + } + if (config.max === undefined || config.max === null) { + this.setMax(FieldAngle.DEFAULT_MAX); + } + if (config.precision === undefined || config.precision === null) { + this.setPrecision(FieldAngle.DEFAULT_PRECISION); + } + } else { + this.setMin(FieldAngle.DEFAULT_MIN); + this.setMax(FieldAngle.DEFAULT_MAX); + this.setPrecision(FieldAngle.DEFAULT_PRECISION); + } + this.setValue(value); + if (validator) { + this.setValidator(validator); + } + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override configure_(config: FieldAngleConfig) { + super.configure_(config); + switch (config.mode) { + case Mode.COMPASS: + this.clockwise = true; + this.offset = 90; + break; + case Mode.PROTRACTOR: + // This is the default mode, so we could do nothing. But just to + // future-proof, we'll set it anyway. + this.clockwise = false; + this.offset = 0; + break; + } + + // Allow individual settings to override the mode setting. + if (config.clockwise !== undefined) this.clockwise = config.clockwise; + if (config.offset !== undefined) this.offset = config.offset; + if (config.displayMin !== undefined) this.displayMin = config.displayMin; + if (config.displayMax !== undefined) this.displayMax = config.displayMax; + if (config.minorTick !== undefined) this.minorTick = config.minorTick; + if (config.majorTick !== undefined) this.majorTick = config.majorTick; + if (config.symbol !== undefined) this.symbol = config.symbol; + + // Sanity check the inputs. + if (this.displayMin >= this.displayMax) { + throw Error('Display min must be larger than display max'); + } + if (this.minorTick < 0 || this.majorTick < 0) { + throw Error('Ticks cannot be negative'); + } + } + + override getAriaTypeName() { + return Blockly.Msg['ARIA_TYPE_FIELD_ANGLE']; + } + + override getAriaValue() { + return Blockly.Msg['ARIA_LABEL_FIELD_ANGLE'].replace( + '%1', + super.getAriaValue() ?? '', + ); + } + + /** + * Create the block UI for this field. + * + * @internal + */ + override initView() { + super.initView(); + if (this.symbol) { + // Add the degree symbol to the left of the number, + // even in RTL (https://github.com/google/blockly/issues/2380). + this.symbolElement = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.TSPAN, + {class: 'blocklyAngleSymbol'}, + ); + this.symbolElement.appendChild(document.createTextNode(this.symbol)); + this.getTextElement().appendChild(this.symbolElement); + } + } + + /** + * Updates the angle when the field rerenders. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override render_() { + super.render_(); + this.updateGraph(); + } + + /** + * Create and show the angle field's editor. + * + * @param e Optional mouse event that triggered the field to open, + * or undefined if triggered programmatically. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override showEditor_(e?: Event) { + // Mobile browsers have issues with in-line textareas (focus & keyboards). + // Also, don't let the parent take ephemeral focus since the drop-down div + // below will handle it, instead. + const noFocus = + Blockly.utils.userAgent.MOBILE || + Blockly.utils.userAgent.ANDROID || + Blockly.utils.userAgent.IPAD; + + const editor = this.dropdownCreate(); + Blockly.DropDownDiv.getContentDiv().appendChild(editor); + + const sourceBlock = this.getSourceBlock(); + if (sourceBlock instanceof Blockly.BlockSvg) { + Blockly.DropDownDiv.setColour( + sourceBlock.style.colourPrimary, + sourceBlock.style.colourTertiary, + ); + } + + Blockly.DropDownDiv.showPositionedByField( + this, + this.dropdownDispose.bind(this), + ); + + super.showEditor_(e, noFocus, false); + + this.updateGraph(); + } + + /** + * Creates the angle dropdown editor. + * + * @returns The newly created slider. + */ + private dropdownCreate(): SVGSVGElement { + const svg = Blockly.utils.dom.createSvgElement(Blockly.utils.Svg.SVG, { + 'xmlns': Blockly.utils.dom.SVG_NS, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'xmlns:html': Blockly.utils.dom.HTML_NS, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'xmlns:xlink': Blockly.utils.dom.XLINK_NS, + 'version': '1.1', + 'height': FieldAngle.HALF * 2 + 'px', + 'width': FieldAngle.HALF * 2 + 'px', + }); + svg.style.touchAction = 'none'; + const circle = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.CIRCLE, + { + cx: FieldAngle.HALF, + cy: FieldAngle.HALF, + r: FieldAngle.RADIUS, + class: 'blocklyAngleCircle', + }, + svg, + ); + this.gauge = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.PATH, + { + class: 'blocklyAngleGauge', + }, + svg, + ); + this.line = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.LINE, + { + x1: FieldAngle.HALF, + y1: FieldAngle.HALF, + class: 'blocklyAngleLine', + }, + svg, + ); + + // Draw markers around the edge. + const minValueDegrees = Blockly.utils.math.toDegrees( + this.fieldAngleToRadians(this.min_), + ); + const maxValueDegrees = Blockly.utils.math.toDegrees( + this.fieldAngleToRadians(this.max_), + ); + + /** + * Draw a set of ticks on the gauge. + * + * @param tickAngle Angle between each tick. + * @param length Length of the tick (minor=5, major=10). + */ + const drawTicks = (tickAngle: number, length: number) => { + let min = Math.ceil(minValueDegrees / tickAngle) * tickAngle; + let max = Math.floor(maxValueDegrees / tickAngle) * tickAngle; + + if (this.clockwise) { + if (min < max) { + min += 360; + } + } else { + if (min > max) { + max += 360; + } + } + if (max === min) { + // Technically this could actually be zero, but more likely it's whole. + max += 360; + } + if (min > max) { + [min, max] = [max, min]; + } + for (let angle = min; angle <= max; angle += tickAngle) { + Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.LINE, + { + x1: FieldAngle.HALF + FieldAngle.RADIUS, + y1: FieldAngle.HALF, + x2: FieldAngle.HALF + FieldAngle.RADIUS - length, + y2: FieldAngle.HALF, + class: 'blocklyAngleMarks', + transform: + 'rotate(' + + -angle + + ',' + + FieldAngle.HALF + + ',' + + FieldAngle.HALF + + ')', + }, + svg, + ); + } + }; + + const displayRange = this.displayMax - this.displayMin; + const minorTickAngle = (360 / displayRange) * this.minorTick; + if (minorTickAngle) { + drawTicks(minorTickAngle, 5); + } + const majorTickAngle = (360 / displayRange) * this.majorTick; + if (majorTickAngle) { + drawTicks(majorTickAngle, 10); + } + + // The angle picker is different from other fields in that it updates on + // mousemove even if it's not in the middle of a drag. In future we may + // change this behaviour. + this.boundEvents.push( + Blockly.browserEvents.conditionalBind(svg, 'click', this, this.hide), + ); + // On touch devices, the picker's value is only updated with a drag. Add + // a click handler on the drag surface to update the value if the surface + // is clicked. + this.boundEvents.push( + Blockly.browserEvents.conditionalBind( + circle, + 'pointerdown', + this, + this.onMouseMove_, + true, + ), + ); + this.boundEvents.push( + Blockly.browserEvents.conditionalBind( + circle, + 'pointermove', + this, + this.onMouseMove_, + true, + ), + ); + return svg; + } + + /** + * Disposes of events belonging to the angle editor. + */ + private dropdownDispose() { + for (const event of this.boundEvents) { + Blockly.browserEvents.unbind(event); + } + this.boundEvents.length = 0; + this.gauge = null; + this.line = null; + } + + /** Hide the editor. */ + private hide() { + this.recomputeAriaContext(); + Blockly.DropDownDiv.hideIfOwner(this); + Blockly.WidgetDiv.hide(); + } + + /** + * Set the angle to match the mouse's position. + * + * @param e Mouse move event. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected onMouseMove_(e: PointerEvent) { + // Calculate angle. + const bBox = this.gauge?.ownerSVGElement?.getBoundingClientRect(); + if (!bBox) { + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + return; + } + const dx = e.clientX - bBox.left - FieldAngle.HALF; + const dy = e.clientY - bBox.top - FieldAngle.HALF; + let angle = Math.atan2(-dy, dx); + if (isNaN(angle)) { + // This shouldn't happen, but let's not let this error propagate further. + return; + } + angle = this.radiansToFieldAngle(angle); + this.displayMouseOrKeyboardValue(angle); + } + + /** + * Convert an on-screen angle into a value for this field. + * + * @param angle Radians where 0: East, π/2: North, π or -π: West, -π/2: South. + * @returns Angle value for this field, scaled and offset as specified. + */ + private radiansToFieldAngle(angle: number): number { + // Convert angle from radians (-π to π) to turns (-0.5 to 0.5). + angle /= 2 * Math.PI; + // Compensate for offset. + angle -= this.offset / 360; + // Flip if clockwise. + if (this.clockwise) { + angle *= -1; + } + // Normalize to positive. + angle %= 1; + if (angle < 0) { + angle += 1; + } + // Convert angle from turns (0.0 to 1.0) to the display min/max range. + angle *= this.displayMax - this.displayMin; + angle += this.displayMin; + return angle; + } + + /** + * Convert a value for this field into an on-screen angle. + * + * @param angle Angle value for this field, scaled and offset as specified. + * @returns Radians where 0: East, π/2: North, π or -π: West, -π/2: South. + */ + private fieldAngleToRadians(angle: number): number { + // Convert angle from the display min/max range to turns (0.0 to 1.0). + angle -= this.displayMin; + angle /= this.displayMax - this.displayMin; + // Flip if clockwise. + if (this.clockwise) { + angle *= -1; + } + // Compensate for offset. + angle += this.offset / 360; + // Normalize to span equally across zero (-0.5 to 0.5). + angle %= 1; + if (angle > 0.5) { + angle -= 1; + } + if (angle < -0.5) { + angle += 1; + } + // Convert angle from turns to radians. + angle *= 2 * Math.PI; + return angle; + } + + /** + * Handles and displays values that are input via mouse or arrow key input. + * These values need to be rounded and wrapped before being displayed so + * that the text input's value is appropriate. + * + * @param angle New angle. + */ + private displayMouseOrKeyboardValue(angle: number) { + const validAngle = this.doClassValidation_(angle); + if (validAngle !== null && validAngle !== this.value_) { + // Intermediate value changes from user input are not confirmed until the + // user closes the editor, and may be numerous. Inhibit reporting these as + // normal block change events, and instead report them as special + // intermediate changes that do not get recorded in undo history. + const oldValue = this.value_; + this.setEditorValue_(validAngle, false); + if ( + this.sourceBlock_ && + Blockly.Events.isEnabled() && + this.value_ !== oldValue + ) { + Blockly.Events.fire( + new (Blockly.Events.get( + Blockly.Events.BLOCK_FIELD_INTERMEDIATE_CHANGE, + ))(this.sourceBlock_, this.name || null, oldValue, this.value_), + ); + Blockly.utils.aria.announceDynamicAriaState(this.getAriaValue()); + } + } + } + + /** Redraw the graph with the current angle. */ + private updateGraph() { + if (!this.gauge || !this.line) { + return; + } + let angle = Number(this.getText()); + if (isNaN(angle)) { + // This shouldn't happen, but let's not let this error propagate further. + return; + } + angle = this.fieldAngleToRadians(angle); + + let path = `M ${FieldAngle.HALF},${FieldAngle.HALF}`; + let x2 = FieldAngle.HALF; + let y2 = FieldAngle.HALF; + if (!isNaN(angle)) { + const angle1 = Blockly.utils.math.toRadians(this.offset); + const x1 = Math.cos(angle1) * FieldAngle.RADIUS; + const y1 = Math.sin(angle1) * -FieldAngle.RADIUS; + x2 += Math.cos(angle) * FieldAngle.RADIUS; + y2 -= Math.sin(angle) * FieldAngle.RADIUS; + // Don't ask how the flag calculations work. They just do. + const clockwiseFlag = Number(this.clockwise); + let largeFlag = Math.abs(Math.floor((angle - angle1) / Math.PI) % 2); + if (clockwiseFlag) { + largeFlag = 1 - largeFlag; + } + path += + ` l ${x1},${y1} A ${FieldAngle.RADIUS},${FieldAngle.RADIUS} 0 ` + + `${largeFlag} ${clockwiseFlag} ${x2},${y2} z`; + } + this.gauge.setAttribute('d', path); + this.line.setAttribute('x2', `${x2}`); + this.line.setAttribute('y2', `${y2}`); + } + + /** + * Handle key down to the editor. + * + * @param e Keyboard event. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override onHtmlInputKeyDown_(e: KeyboardEvent) { + super.onHtmlInputKeyDown_(e); + const block = this.getSourceBlock(); + if (!block) { + throw new Error( + 'The field has not yet been attached to its input. ' + + 'Call appendField to attach it.', + ); + } + + let multiplier = 0; + switch (e.key) { + case 'ArrowLeft': + // decrement (increment in RTL) + multiplier = block.RTL ? 1 : -1; + break; + case 'ArrowRight': + // increment (decrement in RTL) + multiplier = block.RTL ? -1 : 1; + break; + case 'ArrowDown': + // decrement + multiplier = -1; + break; + case 'ArrowUp': + // increment + multiplier = 1; + break; + } + if (multiplier) { + const value = this.getValue() as number; + this.displayMouseOrKeyboardValue(value + multiplier * this.precision_); + e.preventDefault(); + e.stopPropagation(); + } + } + + /** + * Ensure that the input value is a valid angle. + * + * @param newValue The input value. + * @returns A valid angle, or null if invalid. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_( + newValue: number, + ): number | null | undefined; + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_(newValue?: number): number | null; + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_( + newValue?: number, + ): number | null | undefined { + // The obvious approach would be to call super.doClassValidation_ to handle + // min/max limitations. However angle pickers out of range need to clamp + // to the closest min/max point, which may involve a wrap to the opposite + // end of the numeric scale. + // E.g. min/max is 0/180 on a 0/360 display, and value is 365. + // FieldNumber would clamp 365 to max (180), but 0 is closer on the dial. + if (newValue === null) { + return null; + } + let value = Number(newValue); + if (isNaN(value) || !isFinite(value)) { + return null; + } + // Get the value in range. + value = this.wrapValue(value); + // Round to nearest multiple of precision. + if (this.precision_) { + value = Math.round(value / this.precision_) * this.precision_; + } + // Deal with 6.6000000000000005 IEEE float errors. + // Clean up floating point errors. + value = Number(value.toFixed(10)); + // Clamp the value between min and max, noting wrapping. + const displayRange = this.displayMax - this.displayMin; + const valueRange = this.max_ - this.min_; + if (value < this.min_) { + const undershoot = this.min_ - value; + const overshoot = displayRange - undershoot - valueRange; + value = undershoot < overshoot ? this.min_ : this.max_; + } + if (value > this.max_) { + const overshoot = value - this.max_; + const undershoot = displayRange - overshoot - valueRange; + value = undershoot < overshoot ? this.min_ : this.max_; + } + return value; + } + + /** + * Wraps the value so that it is in the min/max display range (e.g. 0 to 360). + * + * @param value The value to wrap. + * @returns The wrapped value. + */ + private wrapValue(value: number): number { + const displayRange = this.displayMax - this.displayMin; + value %= displayRange; + while (value < this.displayMin) { + value += displayRange; + } + while (value >= this.displayMax) { + value -= displayRange; + } + return value; + } + + /** + * Construct a FieldAngle from a JSON arg object. + * + * @param options A JSON object with options + * (value, mode, clockwise, offset, min, max, precision). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static fromJson(options: FieldAngleFromJsonConfig): FieldAngle { + // `this` might be a subclass of FieldAngle if that class doesn't override + // the static fromJson method. + return new this(options.value, undefined, options); + } + + /** + * Hides the angle editor and updates the ARIA label. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override widgetDispose_() { + super.widgetDispose_(); + this.recomputeAriaContext(); + } +} + +/** Register the field and any dependencies. */ +export function registerFieldAngle() { + Blockly.fieldRegistry.register('field_angle', FieldAngle); +} + +FieldAngle.prototype.DEFAULT_VALUE = 0; + +/** + * CSS for angle field. + */ +Blockly.Css.register(` +.blocklyAngleCircle { + stroke: #444; + stroke-width: 1; + fill: #ddd; + fill-opacity: 0.8; +} + +.blocklyAngleMarks { + stroke: #444; + stroke-width: 1; +} + +.blocklyAngleGauge { + fill: #f88; + fill-opacity: 0.8; + pointer-events: none; +} + +.blocklyAngleLine { + stroke: #f00; + stroke-width: 2; + stroke-linecap: round; + pointer-events: none; +} + +.blocklyAngleSymbol { + dominant-baseline: central; +} +`); + +/** + * The two main modes of the angle field. + * Compass specifies: + * - clockwise: true + * - offset: 90 + * - min: 0 + * - max: 360 + * - precision: 15 + * - displayMin: 0 + * - displayMax: 360 + * - minorTick: 15 + * - majorTick: 45 + * - symbol: '°' + * + * Protractor specifies: + * - clockwise: false + * - offset: 0 + * - min: 0 + * - max: 360 + * - precision: 15 + * - displayMin: 0 + * - displayMax: 360 + * - minorTick: 15 + * - majorTick: 45 + * - symbol: '°' + */ +export enum Mode { + COMPASS = 'compass', + PROTRACTOR = 'protractor', +} + +/** + * Extra configuration options for the angle field. + */ +export interface FieldAngleConfig extends Blockly.FieldNumberConfig { + mode?: Mode; + clockwise?: boolean; + offset?: number; + displayMin?: number; + displayMax?: number; + minorTick?: number; + majorTick?: number; + symbol?: string; +} + +/** + * fromJson configuration options for the angle field. + */ +export interface FieldAngleFromJsonConfig extends FieldAngleConfig { + value?: number; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldAngleValidator = Blockly.FieldNumberValidator; diff --git a/packages/plugins/field-angle/src/index.ts b/packages/plugins/field-angle/src/index.ts new file mode 100644 index 00000000000..ab5c4477016 --- /dev/null +++ b/packages/plugins/field-angle/src/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './field_angle'; diff --git a/packages/plugins/field-angle/test/field_angle_test.mocha.js b/packages/plugins/field-angle/test/field_angle_test.mocha.js new file mode 100644 index 00000000000..3b519343eee --- /dev/null +++ b/packages/plugins/field-angle/test/field_angle_test.mocha.js @@ -0,0 +1,328 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const {testHelpers} = require('@blockly/dev-tools'); +const {FieldAngle, registerFieldAngle} = require('../src/index'); +const {assert} = require('chai'); + +const { + assertFieldValue, + FieldCreationTestCase, + FieldValueTestCase, + runConstructorSuiteTests, + runFromJsonSuiteTests, + runSetValueTests, +} = testHelpers; + +suite('FieldAngle', function () { + setup(function () { + registerFieldAngle(); + }); + + /** + * Configuration for field tests with invalid values. + * @type {Array} + */ + const invalidValueTestCases = [ + {title: 'Undefined', value: undefined}, + {title: 'Null', value: null}, + {title: 'NaN', value: NaN}, + {title: 'Non-Parsable String', value: 'bad'}, + {title: 'Infinity', value: Infinity}, + {title: 'Negative Infinity', value: -Infinity}, + {title: 'Infinity String', value: 'Infinity'}, + {title: 'Negative Infinity String', value: '-Infinity'}, + ]; + /** + * Configuration for field tests with valid values. + * @type {Array} + */ + const validValueTestCases = [ + {title: 'Integer', value: 16, expectedValue: 15}, + {title: 'Float', value: 14.5, expectedValue: 15}, + {title: 'Integer String', value: '30', expectedValue: 30}, + {title: 'Float String', value: '0.75', expectedValue: 0}, + {title: '> 360°', value: 360 + 16, expectedValue: 15}, + {title: '< 0°', value: -16, expectedValue: 360 - 15}, + ]; + const addArgsAndJson = function (testCase) { + testCase.args = [testCase.value]; + testCase.json = {value: testCase.value}; + }; + invalidValueTestCases.forEach(addArgsAndJson); + validValueTestCases.forEach(addArgsAndJson); + + /** + * The expected default value for the field being tested. + * @type {*} + */ + const defaultFieldValue = 0; + /** + * Asserts that the field property values are set to default. + * @param {FieldAngle} field The field to check. + */ + const assertFieldDefault = function (field) { + assertFieldValue(field, defaultFieldValue); + }; + /** + * Asserts that the field properties are correct based on the test case. + * @param {FieldAngle} field The field to check. + * @param {!FieldValueTestCase} testCase The test case. + */ + const validTestCaseAssertField = function (field, testCase) { + assertFieldValue(field, testCase.expectedValue); + }; + + runConstructorSuiteTests( + FieldAngle, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + runFromJsonSuiteTests( + FieldAngle, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + suite('setValue', function () { + /** + * Create a mock block that may be used as the source block for a field. + * @returns {!Object} Mock block. + */ + function createBlockMock() { + return { + id: 'test', + rendered: false, + workspace: { + rendered: false, + }, + renameVarById: Blockly.Block.prototype.renameVarById, + updateVarName: Blockly.Block.prototype.updateVarName, + isShadow: () => false, + isDeadOrDying: () => false, + }; + } + + suite('Empty -> New Value', function () { + setup(function () { + this.field = new FieldAngle(); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + defaultFieldValue, + ); + test('With source block', function () { + this.field.setSourceBlock(createBlockMock()); + this.field.setValue(12.5); + assertFieldValue(this.field, 15); + }); + }); + suite('Value -> New Value', function () { + setup(function () { + this.field = new FieldAngle(12.5); + }); + runSetValueTests(validValueTestCases, invalidValueTestCases, 15); + test('With source block', function () { + this.field.setSourceBlock(createBlockMock()); + this.field.setValue(-15); + assertFieldValue(this.field, 360 - 15); + }); + }); + }); + + suite('Validators', function () { + setup(function () { + this.field = new FieldAngle(15); + }); + const testSuites = [ + { + title: 'Null Validator', + validator: function () { + return null; + }, + value: 30, + expectedValue: 15, + }, + { + title: 'Force multiple of 90 Validator', + validator: function (newValue) { + return Math.round(newValue / 90) * 90; + }, + value: 60, + expectedValue: 90, + }, + { + title: 'Returns Undefined Validator', + validator: function () {}, + value: 30, + expectedValue: 30, + }, + ]; + testSuites.forEach(function (suiteInfo) { + suite(suiteInfo.title, function () { + setup(function () { + this.field.setValidator(suiteInfo.validator); + }); + test('New Value', function () { + this.field.setValue(suiteInfo.value); + assertFieldValue( + this.field, + suiteInfo.expectedValue, + suiteInfo.expectedText, + ); + }); + }); + }); + }); + + suite('Customizations', function () { + suite('Clockwise', function () { + test('JS Configuration', function () { + const field = new FieldAngle(0, null, { + clockwise: true, + }); + assert.isTrue(field.clockwise); + }); + test('JSON Definition', function () { + const field = FieldAngle.fromJson({ + value: 0, + clockwise: true, + }); + assert.isTrue(field.clockwise); + }); + }); + + suite('Offset', function () { + test('JS Configuration', function () { + const field = new FieldAngle(0, null, { + offset: 90, + }); + assert.equal(field.offset, 90); + }); + test('JSON Definition', function () { + const field = FieldAngle.fromJson({ + value: 0, + offset: 90, + }); + assert.equal(field.offset, 90); + }); + }); + + suite('Min/Max', function () { + test('JS Configuration', function () { + const field = new FieldAngle(0, null, { + min: -180, + max: 180, + }); + assert.equal(field.getMin(), -180); + assert.equal(field.getMax(), 180); + }); + test('JSON Definition', function () { + const field = FieldAngle.fromJson({ + value: 0, + min: -180, + max: 180, + }); + assert.equal(field.getMin(), -180); + assert.equal(field.getMax(), 180); + }); + }); + + suite('Precision', function () { + test('JS Configuration', function () { + const field = new FieldAngle(0, null, { + precision: 30, + }); + assert.equal(field.getPrecision(), 30); + }); + test('JSON Definition', function () { + const field = FieldAngle.fromJson({ + value: 0, + precision: 30, + }); + assert.equal(field.getPrecision(), 30); + }); + }); + + suite('Mode', function () { + suite('Compass', function () { + test('JS Configuration', function () { + const field = new FieldAngle(0, null, { + mode: 'compass', + }); + assert.equal(field.offset, 90); + assert.isTrue(field.clockwise); + }); + test('JS Configuration', function () { + const field = FieldAngle.fromJson({ + value: 0, + mode: 'compass', + }); + assert.equal(field.offset, 90); + assert.isTrue(field.clockwise); + }); + }); + suite('Protractor', function () { + test('JS Configuration', function () { + const field = new FieldAngle(0, null, { + mode: 'protractor', + }); + assert.equal(field.offset, 0); + assert.isFalse(field.clockwise); + }); + test('JS Configuration', function () { + const field = FieldAngle.fromJson({ + value: 0, + mode: 'protractor', + }); + assert.equal(field.offset, 0); + assert.isFalse(field.clockwise); + }); + }); + }); + }); + + suite('Serialization', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + Blockly.defineBlocksWithJsonArray([ + { + type: 'row_block', + message0: '%1', + args0: [ + { + type: 'input_value', + name: 'INPUT', + }, + ], + output: null, + }, + ]); + + this.assertValue = (value) => { + const block = this.workspace.newBlock('row_block'); + const field = new FieldAngle(value); + block.getInput('INPUT').appendField(field, 'ANGLE'); + const jso = Blockly.serialization.blocks.save(block); + assert.deepEqual(jso['fields'], {ANGLE: value}); + }; + }); + + teardown(function () { + this.workspace.dispose(); + }); + + test('Simple', function () { + this.assertValue(90); + }); + }); +}); diff --git a/packages/plugins/field-angle/test/index.html b/packages/plugins/field-angle/test/index.html new file mode 100644 index 00000000000..c6879bd868c --- /dev/null +++ b/packages/plugins/field-angle/test/index.html @@ -0,0 +1,18 @@ + + + + + Blockly Field Angle Playground + + + + +
+ + + diff --git a/packages/plugins/field-angle/test/index.ts b/packages/plugins/field-angle/test/index.ts new file mode 100644 index 00000000000..643a45a4d58 --- /dev/null +++ b/packages/plugins/field-angle/test/index.ts @@ -0,0 +1,332 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Angle field test playground. + */ + +import * as Blockly from 'blockly'; +import {createPlayground} from '@blockly/dev-tools'; +import {registerFieldAngle} from '../src/index'; + +/** + * An array of blocks that are defined only for the purposes of + * manually and visually testing the angle field. + */ +const testBlockDefinitions = [ + { + type: 'test_standard_field_values', + message0: '%1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values', + message0: '%1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + value: 50, + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_compass', + message0: 'compass %1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + mode: 'compass', + value: 90, + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_protractor', + message0: 'protractor %1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + mode: 'protractor', + value: 90, + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_round', + message0: 'round %1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + precision: 0.1, + value: 123, + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_radians', + message0: 'radians %1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + precision: 0.1, + clockwise: true, + value: 0, + min: 0, + max: Math.PI, + displayMin: -Math.PI, + displayMax: Math.PI, + minorTick: Math.PI / 8, + majorTick: Math.PI, + symbol: ' rad', + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_quadrant', + message0: 'quadrant %1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + precision: 1, + value: 0, + min: 0, + max: 2, + displayMin: 0, + displayMax: 8, + minorTick: 0, + majorTick: 1, + symbol: '', + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_standard_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_angle', + name: 'FIELDNAME', + value: 50, + alt: { + type: 'field_label', + text: `No field_angle`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_parent_block', + message0: 'parent %1', + args0: [ + { + type: 'input_value', + name: 'INPUT', + }, + ], + previousStatement: null, + nextStatement: null, + style: 'loop_blocks', + }, +]; + +Blockly.defineBlocksWithJsonArray(testBlockDefinitions); + +/** + * A test toolbox containing the exported blocks and a variety of + * test blocks to exercise the angle field in different contexts + * (on a shadow block, as the only field on a block, etc). + * These are in a simple toolbox, rather than a category toolbox, so that + * they are all instantiated every time the test page is opened. + */ +const jsonToolbox = { + contents: [ + { + kind: 'label', + text: 'Test blocks: default field values', + }, + { + kind: 'block', + type: 'test_standard_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_standard_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values_and_label', + }, + }, + }, + }, + { + kind: 'label', + text: 'Test blocks: custom field values', + }, + { + kind: 'block', + type: 'test_custom_field_values', + }, + { + kind: 'block', + type: 'test_custom_field_values_compass', + }, + { + kind: 'block', + type: 'test_custom_field_values_protractor', + }, + { + kind: 'block', + type: 'test_custom_field_values_round', + }, + { + kind: 'block', + type: 'test_custom_field_values_radians', + }, + { + kind: 'block', + type: 'test_custom_field_values_quadrant', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_custom_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values_and_label', + }, + }, + }, + }, + ], +}; + +/** + * Create a workspace. + * + * @param blocklyDiv The blockly container div. + * @param options The Blockly options. + * @returns The created workspace. + */ +function createWorkspace( + blocklyDiv: HTMLElement, + options: Blockly.BlocklyOptions, +): Blockly.WorkspaceSvg { + const workspace = Blockly.inject(blocklyDiv, options); + return workspace; +} + +document.addEventListener('DOMContentLoaded', function () { + registerFieldAngle(); + const defaultOptions: Blockly.BlocklyOptions = { + toolbox: jsonToolbox, + }; + const rootElement = document.getElementById('root'); + if (rootElement) { + createPlayground(rootElement, createWorkspace, defaultOptions); + } +}); diff --git a/packages/plugins/field-angle/tsconfig.json b/packages/plugins/field-angle/tsconfig.json new file mode 100644 index 00000000000..99858c525f1 --- /dev/null +++ b/packages/plugins/field-angle/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "es2015", + "moduleResolution": "bundler", + "target": "es6", + "strict": true + }, + // NOTE: `test/**/*` is automatically included in `blockly-scripts start`. + // Only src matters for production builds. + "include": ["src"] +} diff --git a/packages/plugins/field-bitmap/CHANGELOG.md b/packages/plugins/field-bitmap/CHANGELOG.md new file mode 100644 index 00000000000..0c39a8df9e2 --- /dev/null +++ b/packages/plugins/field-bitmap/CHANGELOG.md @@ -0,0 +1,692 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [6.0.9](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-bitmap@6.0.8...@blockly/field-bitmap@6.0.9) (2026-04-09) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [6.0.8](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-bitmap@6.0.7...@blockly/field-bitmap@6.0.8) (2026-01-15) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [6.0.7](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-bitmap@6.0.6...@blockly/field-bitmap@6.0.7) (2026-01-05) + + +### Bug Fixes + +* update plugin URLs to RPF ([#2665](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2665)) ([6c34039](https://github.com/RaspberryPiFoundation/blockly-samples/commit/6c3403960b8070eb8e576bd5702bdb53a09ac475)) + + + +## [6.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@6.0.5...@blockly/field-bitmap@6.0.6) (2026-01-05) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [6.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@6.0.4...@blockly/field-bitmap@6.0.5) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [6.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@6.0.3...@blockly/field-bitmap@6.0.4) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [6.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@6.0.2...@blockly/field-bitmap@6.0.3) (2025-10-02) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [6.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@6.0.1...@blockly/field-bitmap@6.0.2) (2025-07-17) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [6.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@6.0.0...@blockly/field-bitmap@6.0.1) (2025-05-22) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [6.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.17...@blockly/field-bitmap@6.0.0) (2025-05-16) + + +### ⚠ BREAKING CHANGES + +* update all plugins to blockly v12 (#2538) + +### Features + +* update all plugins to blockly v12 ([#2538](https://github.com/google/blockly-samples/issues/2538)) ([420ba31](https://github.com/google/blockly-samples/commit/420ba3124b260b2c06e8e1d61be44e870780d95f)) + + + +## [5.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.16...@blockly/field-bitmap@5.0.17) (2025-05-16) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.15...@blockly/field-bitmap@5.0.16) (2025-05-15) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.14...@blockly/field-bitmap@5.0.15) (2025-02-27) + + +### Bug Fixes + +* [#2463](https://github.com/google/blockly-samples/issues/2463) update event handling for field-bitmap to support touch ([c105002](https://github.com/google/blockly-samples/commit/c1050025e41dd456c822301555b1b3a20577d03e)) + + + + + +## [5.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.13...@blockly/field-bitmap@5.0.14) (2025-02-13) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.12...@blockly/field-bitmap@5.0.13) (2024-12-19) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.11...@blockly/field-bitmap@5.0.12) (2024-12-03) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.10...@blockly/field-bitmap@5.0.11) (2024-11-07) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.9...@blockly/field-bitmap@5.0.10) (2024-09-26) + + +### Bug Fixes + +* **field-*:** Remove unneeded `fieldRegistry.unregister` calls ([#2454](https://github.com/google/blockly-samples/issues/2454)) ([b3ba30e](https://github.com/google/blockly-samples/commit/b3ba30e23dddf0bd98c266659aa229ba6ba685b0)), closes [#2453](https://github.com/google/blockly-samples/issues/2453) + + + + + +## [5.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.8...@blockly/field-bitmap@5.0.9) (2024-08-29) + + +### Bug Fixes + +* predeploy plugins ([#2449](https://github.com/google/blockly-samples/issues/2449)) ([6b36d8b](https://github.com/google/blockly-samples/commit/6b36d8b344a969f79d89bbc7dcee29ae554759f9)) + + + + + +## [5.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.7...@blockly/field-bitmap@5.0.8) (2024-08-22) + + +### Bug Fixes + +* lerna v8 breaking our whole shebangle ([#2446](https://github.com/google/blockly-samples/issues/2446)) ([b8b4c21](https://github.com/google/blockly-samples/commit/b8b4c21d4eaf81f527336ae46f6679ff99ac23c3)) + + + + + +## [5.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.6...@blockly/field-bitmap@5.0.7) (2024-08-08) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.5...@blockly/field-bitmap@5.0.6) (2024-08-01) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.4...@blockly/field-bitmap@5.0.5) (2024-07-25) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.3...@blockly/field-bitmap@5.0.4) (2024-07-11) + + +### Reverts + +* Revert "chore(deps): Bump the npm_and_yarn group across 54 directories with 19 updates (#2403)" (#2410) ([bad8ffb](https://github.com/google/blockly-samples/commit/bad8ffbf85caa4e5b68d2f010cd0deaa9e3dd98f)), closes [#2403](https://github.com/google/blockly-samples/issues/2403) [#2410](https://github.com/google/blockly-samples/issues/2410) + + + +## [5.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.2...@blockly/field-bitmap@5.0.3) (2024-07-04) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.1...@blockly/field-bitmap@5.0.2) (2024-06-06) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [5.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@5.0.0...@blockly/field-bitmap@5.0.1) (2024-05-30) + + +### Bug Fixes + +* multiple blockly instances ([#2375](https://github.com/google/blockly-samples/issues/2375)) ([b231e59](https://github.com/google/blockly-samples/commit/b231e598f2f5f5b0abbfd01d981e35572ad50a26)) +* update pixel with new values ([#2373](https://github.com/google/blockly-samples/issues/2373)) ([d387b9e](https://github.com/google/blockly-samples/commit/d387b9ebcd0b42275231815fa18f61f61eeb04e4)) + + + +## [5.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.1.1...@blockly/field-bitmap@5.0.0) (2024-05-21) + + +### ⚠ BREAKING CHANGES + +* update all plugins to v11 of blockly + +### Features + +* update all plugins to v11 of blockly ([5fcd3e7](https://github.com/google/blockly-samples/commit/5fcd3e7d53eaadffe9bda9a378b404d34b2f8be2)) +* upgrade ts version, fix field validators ([b2d2191](https://github.com/google/blockly-samples/commit/b2d2191ff11b30347b33f95acf6e58f7ce54d004)) + + +### Bug Fixes + +* Call blocks handle both manual disabling and disabled defs ([#2334](https://github.com/google/blockly-samples/issues/2334)) ([5eade55](https://github.com/google/blockly-samples/commit/5eade55779c4022d14ad4472ff32c93c78199887)) +* remove some uses of generateFieldTextBlocks ([#2357](https://github.com/google/blockly-samples/issues/2357)) ([53b3fa4](https://github.com/google/blockly-samples/commit/53b3fa4604aabb1fc5240d41c8d1d389d4b15301)) + + + +## [4.1.1](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.1.0...@blockly/field-bitmap@4.1.1) (2024-05-20) + + +### Bug Fixes + +* improve bitmap field editor styles for large images and/or no buttons ([#2360](https://github.com/google/blockly-samples/issues/2360)) ([4cc25b3](https://github.com/google/blockly-samples/commit/4cc25b3a1374936fdc8eff56d14d3f9429c56504)) + + + +## [4.1.0](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.18...@blockly/field-bitmap@4.1.0) (2024-05-16) + + +### Features + +* Allow configuration of buttons, pixel colors, and fixed field height for Bitmap field ([#2351](https://github.com/google/blockly-samples/issues/2351)) ([432e277](https://github.com/google/blockly-samples/commit/432e277687bc0883806302164054f0a7b9ac3484)) + + + +## [4.0.18](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.17...@blockly/field-bitmap@4.0.18) (2024-05-09) + + +### Bug Fixes + +* Fix incorrect Blockly imports ([#2339](https://github.com/google/blockly-samples/issues/2339)) ([9af40b9](https://github.com/google/blockly-samples/commit/9af40b9ca075275af2b48cedcc1750d458084eb3)) + + + +## [4.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.16...@blockly/field-bitmap@4.0.17) (2024-04-25) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.15...@blockly/field-bitmap@4.0.16) (2024-04-18) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.14...@blockly/field-bitmap@4.0.15) (2024-04-04) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.13...@blockly/field-bitmap@4.0.14) (2024-03-28) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.12...@blockly/field-bitmap@4.0.13) (2024-02-29) + + +### Bug Fixes + +* Fix but preventing clearing/randomizing nonsquare bitmap fields. ([#2226](https://github.com/google/blockly-samples/issues/2226)) ([c32266b](https://github.com/google/blockly-samples/commit/c32266b2b7685c0d4d6646e5bcedb085c435e0b2)) + + + +## [4.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.11...@blockly/field-bitmap@4.0.12) (2024-02-08) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.10...@blockly/field-bitmap@4.0.11) (2023-12-14) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.9...@blockly/field-bitmap@4.0.10) (2023-12-07) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.8...@blockly/field-bitmap@4.0.9) (2023-11-16) + + +### Bug Fixes + +* resolved 15 ESLint warnings ([#2065](https://github.com/google/blockly-samples/issues/2065)) ([2436337](https://github.com/google/blockly-samples/commit/243633746542bb1518fe2893c0421a5a6f79fd32)) + + + +## [4.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.7...@blockly/field-bitmap@4.0.8) (2023-11-09) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.6...@blockly/field-bitmap@4.0.7) (2023-11-02) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.5...@blockly/field-bitmap@4.0.6) (2023-10-30) + + +### Bug Fixes + +* progress for Have field-bitmap fire intermediate change events ([#2012](https://github.com/google/blockly-samples/issues/2012)) ([7050875](https://github.com/google/blockly-samples/commit/7050875971ebb038702232c5bb642a0393592ab7)) + + + +## [4.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.4...@blockly/field-bitmap@4.0.5) (2023-09-14) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.3...@blockly/field-bitmap@4.0.4) (2023-08-17) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.2...@blockly/field-bitmap@4.0.3) (2023-07-27) + + +### Bug Fixes + +* Use the normal, non-drag cursor when hovering over a bitmap field. ([#1810](https://github.com/google/blockly-samples/issues/1810)) ([a4f8e8d](https://github.com/google/blockly-samples/commit/a4f8e8d1c22e38fa00a27bbeaca3ef9f637cbe82)) + + + +## [4.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.1...@blockly/field-bitmap@4.0.2) (2023-07-24) + + +### Bug Fixes + +* Fix the positioning of the dropdown div relative to the bitmap field. ([#1809](https://github.com/google/blockly-samples/issues/1809)) ([54a6ee6](https://github.com/google/blockly-samples/commit/54a6ee64ff03b6cb11e070d130a88d656aeced22)) + + + +## [4.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@4.0.0...@blockly/field-bitmap@4.0.1) (2023-07-20) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [4.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@3.0.1...@blockly/field-bitmap@4.0.0) (2023-07-13) + + +### ⚠ BREAKING CHANGES + +* updated `plugins/field-date` to TypeScript (#1705) + +### Features + +* updated `plugins/field-date` to TypeScript ([#1705](https://github.com/google/blockly-samples/issues/1705)) ([e5531ff](https://github.com/google/blockly-samples/commit/e5531fffe188ee361a16fe48ed126b34e51a8d30)) + + + +## [3.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@3.0.0...@blockly/field-bitmap@3.0.1) (2023-07-06) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [3.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.19...@blockly/field-bitmap@3.0.0) (2023-06-28) + + +### ⚠ BREAKING CHANGES + +* update all plugins to use blockly-v10.0.0 (#1744) + +### Features + +* update all plugins to use blockly-v10.0.0 ([#1744](https://github.com/google/blockly-samples/issues/1744)) ([6cc88cb](https://github.com/google/blockly-samples/commit/6cc88cbef39d4ad664a668d3d46eb29ba7292f9c)) + + + +## [2.0.19](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.18...@blockly/field-bitmap@2.0.19) (2023-06-22) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.18](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.17...@blockly/field-bitmap@2.0.18) (2023-05-18) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.16...@blockly/field-bitmap@2.0.17) (2023-05-11) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.15...@blockly/field-bitmap@2.0.16) (2023-05-04) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.14...@blockly/field-bitmap@2.0.15) (2023-04-27) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.13...@blockly/field-bitmap@2.0.14) (2023-03-23) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.12...@blockly/field-bitmap@2.0.13) (2023-03-09) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.11...@blockly/field-bitmap@2.0.12) (2023-03-02) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.10...@blockly/field-bitmap@2.0.11) (2023-02-23) + + +### Bug Fixes + +* remove prepublishOnly scripts ([#1579](https://github.com/google/blockly-samples/issues/1579)) ([27da6cd](https://github.com/google/blockly-samples/commit/27da6cd04c38f6ba417f4e7446bb6218c475448d)) + + + +## [2.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.9...@blockly/field-bitmap@2.0.10) (2023-02-23) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.8...@blockly/field-bitmap@2.0.9) (2023-02-16) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.7...@blockly/field-bitmap@2.0.8) (2023-02-09) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.6...@blockly/field-bitmap@2.0.7) (2023-01-26) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.5...@blockly/field-bitmap@2.0.6) (2023-01-05) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.4...@blockly/field-bitmap@2.0.5) (2022-12-15) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.3...@blockly/field-bitmap@2.0.4) (2022-12-01) + + +### Bug Fixes + +* **field-bitmap:** Fix crash when dismissing FieldBitmap dropdown ([#1394](https://github.com/google/blockly-samples/issues/1394)) ([9a2ea9c](https://github.com/google/blockly-samples/commit/9a2ea9cfe43bd7d87b809c0b0dc8a807e958630d)), closes [#1386](https://github.com/google/blockly-samples/issues/1386) + + + +## [2.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.2...@blockly/field-bitmap@2.0.3) (2022-11-10) + + +### Bug Fixes + +* field-bitmap now serializing correctly ([#1361](https://github.com/google/blockly-samples/issues/1361)) ([304a875](https://github.com/google/blockly-samples/commit/304a875648bac4c7d78747a298dc8b2a1d28ba5b)) + + + +## [2.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.1...@blockly/field-bitmap@2.0.2) (2022-10-27) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@2.0.0...@blockly/field-bitmap@2.0.1) (2022-10-13) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## [2.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-bitmap@1.0.7...@blockly/field-bitmap@2.0.0) (2022-10-05) + + +### ⚠ BREAKING CHANGES + +* update peer and devDependencies of all plugins to require Blockly v9 (#1314) + +### Bug Fixes + +* references to deprecated functions in v9 ([#1313](https://github.com/google/blockly-samples/issues/1313)) ([cb2e679](https://github.com/google/blockly-samples/commit/cb2e67987e0b62a77c26adc660cc6ade1ba67954)) + + +### Miscellaneous Chores + +* update peer and devDependencies of all plugins to require Blockly v9 ([#1314](https://github.com/google/blockly-samples/issues/1314)) ([03d4912](https://github.com/google/blockly-samples/commit/03d4912c42c8de0f30493037ccc28dddaea0f266)) + + + +## 1.0.7 (2022-08-11) + + +### Bug Fixes + +* Remove the deprecated block-extension-tooltip ([#1215](https://github.com/google/blockly-samples/issues/1215)) ([a044478](https://github.com/google/blockly-samples/commit/a044478c86a73e3065bc866e427f175cbec6fc13)) + + + + + +## 1.0.6 (2022-08-04) + + +### Bug Fixes + +* fix the name of the package of suggested-blocks ([#1214](https://github.com/google/blockly-samples/issues/1214)) ([35aa8ec](https://github.com/google/blockly-samples/commit/35aa8ec73a60a4eb5b1e80cb2fc71dcd83d05e27)) + + + + + +## 1.0.4 (2022-07-21) + +**Note:** Version bump only for package @blockly/field-bitmap + + + + + +## 1.0.3 (2022-07-06) + + +### Bug Fixes + +* Update blockly-react to use functional components instead of class components. ([#1178](https://github.com/google/blockly-samples/issues/1178)) ([fa21187](https://github.com/google/blockly-samples/commit/fa21187cdbe4ec3a5c69f185540dd68a98eb69d7)) + + + + + +## 1.0.2 (2022-06-27) + + +### Bug Fixes + +* Update package import for keyboard navigation demo ([#1170](https://github.com/google/blockly-samples/issues/1170)) ([69c1725](https://github.com/google/blockly-samples/commit/69c1725b775279fcc397dc178935208d5f42b08c)) + + + + + +## 1.0.1 (2022-06-21) + +**Note:** Version bump only for package @blockly/field-bitmap diff --git a/packages/plugins/field-bitmap/README.md b/packages/plugins/field-bitmap/README.md new file mode 100644 index 00000000000..3b8cee4efd9 --- /dev/null +++ b/packages/plugins/field-bitmap/README.md @@ -0,0 +1,109 @@ +# @blockly/field-bitmap [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +A [Blockly](https://www.npmjs.com/package/blockly) field that allows for user-inputted pixel grids. The image value is stored as a 2D array of 1s and 0s, and supports any size. The user can paint over pixels with their mouse, or randomize the grid. + +Note: this field does not support serialization to XML. + +## Installation + +### Yarn + +``` +yarn add @blockly/field-bitmap +``` + +### npm + +``` +npm install @blockly/field-bitmap --save +``` + +## Usage + +This field accepts up to 6 parameters: + +- `"value"` to specify an initial value. Must be a 2D rectangular array of 1s and 0s. + If not provided, the default is an empty grid of the specified size. +- `"width"` to specify an initial width, if there is no initial value. + If not provided, the default is a width of 5. +- `"height"` to specify an initial height, if there is no initial value. + If not provided, the default is a height of 5. +- `fieldHeight"` to specify a static field height. If provided, the individual pixels + will be resized to fit inside the field. This only affects the field as it is + seen on a block and not the pop-up editor. Good for larger images. (_Note: If this + results in fractional pixel sizes, the overall field height may not exactly match + the specified value on all browsers._) +- `"colours"` to override the default colours, Default values: + `{filled: '#363d80', empty: '#fff'}` +- `"buttons"` to show or hide the "Randomize" and/or "Clear" buttons. If either is + omitted, the button will be shown. Default values: + `{randomize: true, clear: true}` + +### JavaScript + +```js +import * as Blockly from 'blockly'; +import {FieldBitmap} from 'blockly-field-bitmap'; +Blockly.Blocks["test_field_bitmap"] = { + init: function () { + this.appendDummyInput() + .appendField("bitmap: ") + .appendField(new FieldBitmap(...), "FIELDNAME"); + } +}; +``` + +### JSON + +Example with default value: + +```js +import * as Blockly from 'blockly'; +import '@blockly/field-bitmap'; +Blockly.defineBlocksWithJsonArray([ + { + type: 'test_field_bitmap', + message0: 'bitmap: %1', + args0: [ + { + type: 'field_bitmap', + name: 'FIELDNAME', + value: [ + [0, 0, 0, 0, 0, 0, 0], + [0, 1, 1, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 1, 1, 1, 1, 1, 0], + [0, 1, 0, 0, 0, 1, 0], + [0, 0, 1, 1, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + ], + }, + ], + }, +]); +``` + +Example with width and height: + +```js +import * as Blockly from 'blockly'; +import '@blockly/field-bitmap'; +Blockly.defineBlocksWithJsonArray([ + { + type: 'test_field_bitmap', + message0: 'bitmap: %1', + args0: [ + { + type: 'field_bitmap', + name: 'FIELDNAME', + width: 8, + height: 8, + }, + ], + }, +]); +``` + +## License + +Apache 2.0 diff --git a/packages/plugins/field-bitmap/package.json b/packages/plugins/field-bitmap/package.json new file mode 100644 index 00000000000..5bdd8e40696 --- /dev/null +++ b/packages/plugins/field-bitmap/package.json @@ -0,0 +1,56 @@ +{ + "name": "@blockly/field-bitmap", + "version": "13.1.0", + "private": true, + "description": "A field that lets users input a pixel grid with their mouse.", + "scripts": { + "audit:fix": "blockly-scripts auditFix", + "build": "blockly-scripts build", + "clean": "blockly-scripts clean", + "lint": "eslint .", + "predeploy": "blockly-scripts predeploy", + "start": "blockly-scripts start", + "test": "blockly-scripts test" + }, + "main": "./dist/index.js", + "module": "./src/index.js", + "unpkg": "./dist/index.js", + "author": "", + "keywords": [ + "blockly", + "blockly-plugin", + "blockly-field", + "bitmap" + ], + "homepage": "https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/field-bitmap#readme", + "bugs": { + "url": "https://github.com/RaspberryPiFoundation/blockly/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaspberryPiFoundation/blockly.git", + "directory": "packages/plugins/field-bitmap" + }, + "license": "Apache-2.0", + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src" + ], + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "mocha": "^11.7.6", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/field-bitmap/src/field-bitmap.ts b/packages/plugins/field-bitmap/src/field-bitmap.ts new file mode 100644 index 00000000000..5c061e9590a --- /dev/null +++ b/packages/plugins/field-bitmap/src/field-bitmap.ts @@ -0,0 +1,1027 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +export const DEFAULT_HEIGHT = 5; +export const DEFAULT_WIDTH = 5; +const DEFAULT_PIXEL_SIZE = 15; +const DEFAULT_PIXEL_COLOURS: PixelColours = { + empty: '#fff', + filled: '#363d80', +}; +const DEFAULT_BUTTONS: Buttons = { + randomize: true, + clear: true, +}; + +/** + * Field for inputting a small bitmap image. + * Includes a grid of clickable pixels that's exported as a bitmap. + */ +export class FieldBitmap extends Blockly.Field { + private initialValue: number[][] | null = null; + private imgHeight: number; + private imgWidth: number; + /** + * Array holding info needed to unbind events. + * Used for disposing. + */ + private boundEvents: Blockly.browserEvents.Data[] = []; + /** References to UI elements */ + private pixelGrid: HTMLDivElement | null = null; + private editorPixels: HTMLButtonElement[][] | null = null; + private blockDisplayPixels: SVGElement[][] | null = null; + /** Index of the keyboard-focused pixel in row-major order, or -1. */ + private focusedPixelIndex = -1; + /** Stateful variables */ + private pointerIsDown = false; + private valToPaintWith?: number; + buttonOptions: Buttons; + pixelSize: number; + pixelColours: {empty: string; filled: string}; + fieldHeight?: number; + + protected override ariaTypeName = Blockly.Msg['ARIA_TYPE_FIELD_BITMAP']; + + /** + * Constructor for the bitmap field. + * + * @param value 2D rectangular array of 1s and 0s. + * @param validator A function that is called to validate. + * @param config Config A map of options used to configure the field. + */ + constructor( + value: number[][] | typeof Blockly.Field.SKIP_SETUP, + validator?: Blockly.FieldValidator, + config?: FieldBitmapFromJsonConfig, + ) { + super(value, validator, config); + + this.SERIALIZABLE = true; + this.buttonOptions = {...DEFAULT_BUTTONS, ...config?.buttons}; + this.pixelColours = {...DEFAULT_PIXEL_COLOURS, ...config?.colours}; + + // Configure value, height, and width + const currentValue = this.getValue(); + if (currentValue !== null) { + this.imgHeight = currentValue.length; + this.imgWidth = currentValue[0].length || 0; + } else { + this.imgHeight = config?.height ?? DEFAULT_HEIGHT; + this.imgWidth = config?.width ?? DEFAULT_WIDTH; + // Set a default empty value + this.setValue(this.getEmptyArray()); + } + this.fieldHeight = config?.fieldHeight; + if (this.fieldHeight) { + this.pixelSize = this.fieldHeight / this.imgHeight; + } else { + this.pixelSize = DEFAULT_PIXEL_SIZE; + } + } + + /** + * Constructs a FieldBitmap from a JSON arg object. + * + * @param options A JSON object with options. + * @returns The new field instance. + */ + static fromJson(options: FieldBitmapFromJsonConfig) { + // `this` might be a subclass of FieldBitmap if that class doesn't override the static fromJson method. + return new this( + options.value ?? Blockly.Field.SKIP_SETUP, + undefined, + options, + ); + } + + /** + * Returns the width of the image in pixels. + * + * @returns The width in pixels. + */ + getImageWidth() { + return this.imgWidth; + } + + /** + * Returns the height of the image in pixels. + * + * @returns The height in pixels. + */ + getImageHeight() { + return this.imgHeight; + } + + /** + * Updates the ARIA roles and label for this field. + */ + override recomputeAriaContext(): boolean { + const shouldCustomize = super.recomputeAriaContext(); + if (!shouldCustomize) return false; + const focusableElement = this.getFocusableElement(); + Blockly.utils.aria.setState( + focusableElement, + Blockly.utils.aria.State.HASPOPUP, + 'grid', + ); + Blockly.utils.aria.setState( + focusableElement, + Blockly.utils.aria.State.EXPANDED, + !!this.pixelGrid, + ); + return true; + } + + /** + * Validates that a new value meets the requirements for a valid bitmap array. + * + * @param newValue The new value to be tested. + * @returns The new value if it's valid, or null. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_( + newValue: number[][], + ): number[][] | null | undefined; + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_( + newValue?: number[][], + ): number[][] | null; + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_( + newValue?: number[][], + ): number[][] | null | undefined { + if (!newValue) { + return null; + } + // Check if the new value is an array + if (!Array.isArray(newValue)) { + return null; + } + const newHeight = newValue.length; + // The empty list is not an acceptable bitmap + if (newHeight == 0) { + return null; + } + + // Check that the width matches the existing width of the image if it + // already has a value. + const newWidth = newValue[0].length; + for (const row of newValue) { + if (!Array.isArray(row)) { + return null; + } + if (row.length !== newWidth) { + return null; + } + } + + // Check if all contents of the arrays are either 0 or 1 + for (const row of newValue) { + for (const cell of row) { + if (cell !== 0 && cell !== 1) { + return null; + } + } + } + return newValue; + } + + /** + * Called when a new value has been validated and is about to be set. + * + * @param newValue The value that's about to be set. + */ + // eslint-disable-next-line + protected override doValueUpdate_(newValue: number[][]) { + super.doValueUpdate_(newValue); + if (newValue) { + this.imgHeight = newValue.length; + this.imgWidth = newValue[0] ? newValue[0].length : 0; + // If the field height is static, adjust the pixel size to fit. + if (this.fieldHeight) { + this.pixelSize = this.fieldHeight / this.imgHeight; + } else { + this.pixelSize = DEFAULT_PIXEL_SIZE; + } + } + this.recomputeAriaContext(); + } + + /** + * Show the bitmap editor dialog. + * + * @param e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + */ + // eslint-disable-next-line + protected override showEditor_(e?: Event) { + const editor = this.dropdownCreate(); + Blockly.DropDownDiv.getContentDiv().appendChild(editor); + Blockly.DropDownDiv.showPositionedByField( + this, + this.dropdownDispose.bind(this), + ); + this.focusPixelAt(0); + this.recomputeAriaContext(); + } + + /** + * Updates the block display and editor dropdown when the field re-renders. + */ + // eslint-disable-next-line + protected override render_() { + super.render_(); + + if (!this.getValue()) { + return; + } + + if (this.blockDisplayPixels) { + const display = this.blockDisplayPixels; + this.forAllCells((r, c) => { + const pixel = this.getPixel(r, c); + display[r][c].style.fill = pixel + ? this.pixelColours.filled + : this.pixelColours.empty; + }); + } + if (this.editorPixels) { + this.forAllCells((r, c) => { + this.updateEditorPixelDisplay(r, c, this.getPixel(r, c)); + }); + } + } + + override getAriaValue(): string | null { + // Get a label for the bitmap's dimensions and the number of pixels that are on. + const value = this.getValue(); + if (!value) { + return null; + } + const height = value.length; + const width = value[0].length; + let onCount = 0; + for (const row of value) { + for (const cell of row) { + if (cell === 1) { + onCount++; + } + } + } + + return (Blockly.Msg['FIELD_BITMAP_ARIA_VALUE'] ?? '%1 by %2, %3 pixels on') + .replace('%1', String(width)) + .replace('%2', String(height)) + .replace('%3', String(onCount)); + } + + /** + * Determines whether the field is editable. + * + * @returns True since it is always editable. + */ + override updateEditable() { + const editable = super.updateEditable(); + // Blockly.Field's implementation sets these classes as appropriate, but + // since this field has no text they just mess up the rendering of the grid + // lines. + const svgRoot = this.getSvgRoot(); + if (svgRoot) { + Blockly.utils.dom.removeClass(svgRoot, 'blocklyNonEditableField'); + Blockly.utils.dom.removeClass(svgRoot, 'blocklyEditableField'); + } + return editable; + } + + /** + * Gets the rectangle built out of dimensions matching SVG's element. + * + * @returns The newly created rectangle of same size as the SVG element. + */ + override getScaledBBox() { + const boundingBox = this.getSvgRoot()?.getBoundingClientRect(); + if (!boundingBox) { + throw new Error('Tried to retrieve a bounding box without a rect'); + } + return new Blockly.utils.Rect( + boundingBox.top, + boundingBox.bottom, + boundingBox.left, + boundingBox.right, + ); + } + + /** + * Creates the bitmap editor and add event listeners. + * + * @returns The newly created dropdown menu. + */ + private dropdownCreate() { + const dropdownEditor = this.createElementWithClassname( + 'div', + 'dropdownEditor', + ); + if (this.buttonOptions.randomize || this.buttonOptions.clear) { + dropdownEditor.classList.add('has-buttons'); + } + + // This prevents the normal max-height from adding a scroll bar for large images. + Blockly.DropDownDiv.getContentDiv().classList.add('contains-bitmap-editor'); + + this.bindEvent(dropdownEditor, 'pointermove', this.onPointerMove); + this.bindEvent(dropdownEditor, 'pointerup', this.onPointerEnd); + this.bindEvent(dropdownEditor, 'pointerleave', this.onPointerEnd); + this.bindEvent(dropdownEditor, 'pointerdown', this.onPointerStart); + this.bindEvent(dropdownEditor, 'pointercancel', this.onPointerEnd); + this.bindEvent(dropdownEditor, 'keydown', this.onEditorKeyDown); + // Stop the browser from handling touch events and cancelling the event. + this.bindEvent(dropdownEditor, 'touchmove', (e: Event) => { + e.preventDefault(); + }); + + const rtl = !!this.getSourceBlock()?.workspace.RTL; + this.pixelGrid = this.createPixelGrid(rtl); + dropdownEditor.appendChild(this.pixelGrid); + + // Add control buttons below the pixel grid + if (this.buttonOptions.randomize) { + this.addControlButton( + dropdownEditor, + // For backwards compatibility, use the old message if it exists, otherwise use the new message. + Blockly.Msg['BUTTON_LABEL_RANDOMIZE'] ?? + Blockly.Msg['FIELD_BITMAP_BUTTON_LABEL_RANDOMIZE'], + this.randomizePixels, + ); + } + if (this.buttonOptions.clear) { + this.addControlButton( + dropdownEditor, + // For backwards compatibility, use the old message if it exists, otherwise use the new message. + Blockly.Msg['BUTTON_LABEL_CLEAR'] ?? + Blockly.Msg['FIELD_BITMAP_BUTTON_LABEL_CLEAR'], + this.clearPixels, + ); + } + + // Store the initial value at the start of the edit. + this.initialValue = this.getValue(); + + return dropdownEditor; + } + + /** + * Builds the accessible pixel grid DOM. + * + * @param rtl Whether the workspace is RTL. + * @returns The grid root element. + */ + private createPixelGrid(rtl: boolean): HTMLDivElement { + const grid = document.createElement('div'); + grid.className = 'bitmapPixelGrid'; + grid.tabIndex = 0; + Blockly.utils.aria.setRole(grid, Blockly.utils.aria.Role.GRID); + grid.style.setProperty('--bitmap-columns', `${this.imgWidth}`); + + this.boundEvents.push( + Blockly.browserEvents.bind(grid, 'keydown', this, (e: KeyboardEvent) => { + this.onPixelGridKeyDown(e, rtl); + }), + ); + + this.editorPixels = []; + for (let r = 0; r < this.imgHeight; r++) { + const row = document.createElement('div'); + row.className = 'bitmapPixelRow'; + Blockly.utils.aria.setRole(row, Blockly.utils.aria.Role.ROW); + grid.appendChild(row); + + this.editorPixels.push([]); + for (let c = 0; c < this.imgWidth; c++) { + const cell = document.createElement('div'); + Blockly.utils.aria.setRole(cell, Blockly.utils.aria.Role.GRIDCELL); + + const button = document.createElement('button'); + button.type = 'button'; + button.id = Blockly.utils.idGenerator.getNextUniqueId(); + button.className = 'pixelButton'; + button.setAttribute('data-row', r.toString()); + button.setAttribute('data-col', c.toString()); + // Keyboard activation is handled on the grid; avoid a second toggle + // from the native button click after pointer paint. + button.addEventListener('click', (e) => e.preventDefault()); + + cell.appendChild(button); + row.appendChild(cell); + this.editorPixels[r].push(button); + this.updateEditorPixelDisplay(r, c, this.getPixel(r, c)); + } + } + return grid; + } + + /** + * Handles editor-level keyboard shortcuts. + * Ctrl/Cmd+Enter commits and closes; Escape reverts and closes. + * + * @param e The keydown event. + */ + private onEditorKeyDown(e: KeyboardEvent) { + const isEscape = e.key === 'Escape'; + const isCommit = e.key === 'Enter' && (e.ctrlKey || e.metaKey); + if (!isEscape && !isCommit) return; + + if (isEscape && this.initialValue !== null) { + this.setValue(this.initialValue, false); + } + Blockly.DropDownDiv.hideIfOwner(this); + Blockly.getFocusManager().focusNode(this); + e.preventDefault(); + e.stopPropagation(); + } + + /** + * Handles keyboard navigation and activation inside the pixel grid. + * + * @param e The keydown event. + * @param rtl Whether the workspace is RTL. + */ + private onPixelGridKeyDown(e: KeyboardEvent, rtl: boolean) { + if ( + !this.editorPixels || + e.shiftKey || + e.ctrlKey || + e.metaKey || + e.altKey + ) { + return; + } + + const length = this.imgWidth * this.imgHeight; + if (!length) return; + + if (this.focusedPixelIndex < 0) { + this.focusedPixelIndex = 0; + } + + switch (e.key) { + case 'ArrowUp': + case 'ArrowDown': + case 'ArrowLeft': + case 'ArrowRight': { + const next = this.getNextPixelIndex(this.focusedPixelIndex, e.key, rtl); + if (next === null) { + (this.getSourceBlock()?.workspace as Blockly.WorkspaceSvg | null) + ?.getAudioManager() + .playErrorBeep(); + e.preventDefault(); + e.stopPropagation(); + return; + } + this.focusPixelAt(next); + break; + } + case 'PageUp': + case 'Home': + this.focusPixelAt(0); + break; + case 'PageDown': + case 'End': + this.focusPixelAt(length - 1); + break; + case 'Enter': + case ' ': + case 'Space': { + const [r, c] = this.indexToCoords(this.focusedPixelIndex); + this.togglePixel(r, c); + e.preventDefault(); + e.stopPropagation(); + return; + } + default: + return; + } + e.preventDefault(); + e.stopPropagation(); + } + + /** + * Returns the next pixel index for an arrow key, or null at hard edges. + * + * @param current Current focused index. + * @param key Arrow key. + * @param rtl Whether the workspace is RTL. + * @returns Next index, or null if navigation should no-op. + */ + private getNextPixelIndex( + current: number, + key: string, + rtl: boolean, + ): number | null { + const col = current % this.imgWidth; + const row = Math.floor(current / this.imgWidth); + const length = this.imgWidth * this.imgHeight; + + let effectiveKey = key; + if (rtl) { + if (key === 'ArrowLeft') effectiveKey = 'ArrowRight'; + else if (key === 'ArrowRight') effectiveKey = 'ArrowLeft'; + } + + switch (effectiveKey) { + case 'ArrowLeft': + return col > 0 ? current - 1 : null; + case 'ArrowRight': + return col < this.imgWidth - 1 && current + 1 < length + ? current + 1 + : null; + case 'ArrowUp': + return row > 0 ? current - this.imgWidth : null; + case 'ArrowDown': + return current + this.imgWidth < length + ? current + this.imgWidth + : null; + default: + return null; + } + } + + /** + * Focuses the pixel button at the given row-major index. + * + * @param index Pixel index. + */ + private focusPixelAt(index: number) { + if (!this.editorPixels || !this.pixelGrid) return; + const length = this.imgWidth * this.imgHeight; + if (index < 0 || index >= length) return; + + this.focusedPixelIndex = index; + const [r, c] = this.indexToCoords(index); + const button = this.editorPixels[r][c]; + button.focus({preventScroll: true}); + Blockly.utils.aria.setState( + this.pixelGrid, + Blockly.utils.aria.State.ACTIVEDESCENDANT, + button.id, + ); + } + + /** + * Converts a pixel index to coordinates. + * + * @param index Pixel index. + * @returns Row and column. + */ + private indexToCoords(index: number): [number, number] { + return [Math.floor(index / this.imgWidth), index % this.imgWidth]; + } + + /** + * Builds an accessible label for a pixel cell. + * + * @param r Row index (0-based). + * @param c Column index (0-based). + * @param pixelValue Pixel value (0 or 1). + * @returns Localized aria label. + */ + private getPixelAriaLabel(r: number, c: number, pixelValue: number): string { + const state = pixelValue + ? (Blockly.Msg['FIELD_BITMAP_PIXEL_ON'] ?? 'on') + : (Blockly.Msg['FIELD_BITMAP_PIXEL_OFF'] ?? 'off'); + return (Blockly.Msg['FIELD_BITMAP_PIXEL_LABEL'] ?? '%1, row %2, column %3') + .replace('%1', state) + .replace('%2', String(r + 1)) + .replace('%3', String(c + 1)); + } + + /** + * Toggles a pixel and updates the editor presentation. + * + * @param r Row index. + * @param c Column index. + * @returns The new pixel value. + */ + private togglePixel(r: number, c: number): number { + const newPixelValue = 1 - this.getPixel(r, c); + this.setPixel(r, c, newPixelValue); + return newPixelValue; + } + + /** + * Syncs one editor pixel's colour, pressed state, and aria label. + * + * @param r Row index. + * @param c Column index. + * @param pixelValue Pixel value (0 or 1). + */ + private updateEditorPixelDisplay(r: number, c: number, pixelValue: number) { + const button = this.editorPixels?.[r]?.[c]; + if (!button) return; + button.style.background = pixelValue + ? this.pixelColours.filled + : this.pixelColours.empty; + button.setAttribute('aria-pressed', String(!!pixelValue)); + Blockly.utils.aria.setState( + button, + Blockly.utils.aria.State.LABEL, + this.getPixelAriaLabel(r, c, pixelValue), + ); + } + + /** + * Initializes the on-block display. + */ + override initView() { + this.createBorderRect_(); + // Invisible fill so the rect only provides keyboard-nav focus stroke. + this.getBorderRect().style.fill = 'none'; + // Don't intercept clicks meant for the pixel rects beneath. + this.getBorderRect().style.pointerEvents = 'none'; + if (this.fieldGroup_) { + Blockly.utils.dom.addClass(this.fieldGroup_, 'blocklyField'); + } + this.blockDisplayPixels = []; + for (let r = 0; r < this.imgHeight; r++) { + const row = []; + for (let c = 0; c < this.imgWidth; c++) { + const square = Blockly.utils.dom.createSvgElement( + 'rect', + { + x: c * this.pixelSize, + y: r * this.pixelSize, + width: this.pixelSize, + height: this.pixelSize, + fill: this.pixelColours.empty, + fill_opacity: 1, // eslint-disable-line + }, + this.getSvgRoot(), + ); + row.push(square); + } + this.blockDisplayPixels.push(row); + } + // SVG strokes are centered on the path; paint the border above the + // pixels so the inner half of the focus ring isn't covered. + this.getSvgRoot()?.appendChild(this.getBorderRect()); + this.recomputeAriaContext(); + } + + /** + * Updates the size of the block based on the size of the underlying image. + */ + // eslint-disable-next-line + protected override updateSize_() { + { + const newWidth = this.pixelSize * this.imgWidth; + const newHeight = this.pixelSize * this.imgHeight; + if (this.borderRect_) { + this.borderRect_.setAttribute('width', String(newWidth)); + this.borderRect_.setAttribute('height', String(newHeight)); + } + + this.size_.width = newWidth; + this.size_.height = newHeight; + } + } + + /** + * Create control button. + * + * @param parent Parent HTML element to which control button will be added. + * @param buttonText Text of the control button. + * @param onClick Callback that will be attached to the control button. + */ + private addControlButton( + parent: HTMLElement, + buttonText: string, + onClick: () => void, + ) { + const button = this.createElementWithClassname('button', 'controlButton'); + button.innerText = buttonText; + parent.appendChild(button); + this.bindEvent(button, 'click', onClick); + } + + /** + * Disposes of events belonging to the bitmap editor. + */ + private dropdownDispose() { + if ( + this.getSourceBlock() && + this.initialValue !== null && + this.initialValue !== this.getValue() + ) { + Blockly.Events.fire( + new (Blockly.Events.get(Blockly.Events.BLOCK_CHANGE))( + this.sourceBlock_, + 'field', + this.name || null, + this.initialValue, + this.getValue(), + ), + ); + } + + for (const event of this.boundEvents) { + Blockly.browserEvents.unbind(event); + } + this.boundEvents.length = 0; + // Keep aria-expanded accurate on later recomputes. + this.pixelGrid = null; + this.editorPixels = null; + this.focusedPixelIndex = -1; + this.pointerIsDown = false; + this.valToPaintWith = undefined; + // Set this.initialValue back to null. + this.initialValue = null; + + Blockly.DropDownDiv.getContentDiv().classList.remove( + 'contains-bitmap-editor', + ); + this.recomputeAriaContext(); + } + + /** + * Constructs an array of zeros with the specified width and height. + * + * @returns The new value. + */ + private getEmptyArray(): number[][] { + const newVal: number[][] = []; + for (let r = 0; r < this.imgHeight; r++) { + newVal.push([]); + for (let c = 0; c < this.imgWidth; c++) { + newVal[r].push(0); + } + } + return newVal; + } + + /** + * Toggles the pixel under the pointer and starts a drag-paint gesture. + * + * @param e The down event. + */ + private onPointerStart(e: PointerEvent) { + if (e.button !== 0) return; + const pixelCoords = this.getPixelCoordsFromElement(e.target as Element); + if (pixelCoords) { + const newPixelValue = this.togglePixel(pixelCoords.r, pixelCoords.c); + this.pointerIsDown = true; + this.valToPaintWith = newPixelValue; + this.focusPixelAt(pixelCoords.r * this.imgWidth + pixelCoords.c); + // Keep receiving move/up outside the editor so drag-paint can continue + // (and pointerleave does not end the gesture). + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + e.preventDefault(); + } + } + + /** + * Moves focus (and paints when dragging) as the pointer moves over pixels. + * + * @param e The move event. + */ + private onPointerMove(e: PointerEvent) { + // Same guard as field-grid-dropdown: ignore moves with no pointer delta + // (e.g. content scrolled under a still pointer). + if (!(e.movementX || e.movementY)) { + return; + } + const currentElement = document.elementFromPoint(e.clientX, e.clientY); + const pixelCoords = this.getPixelCoordsFromElement(currentElement); + if (pixelCoords) { + this.focusPixelAt(pixelCoords.r * this.imgWidth + pixelCoords.c); + if (this.pointerIsDown) { + this.updatePixelValue(pixelCoords.r, pixelCoords.c); + } + } + if (this.pointerIsDown) { + e.preventDefault(); + } + } + + /** + * Reads row/column indices from a pixel button or a descendant. + * + * @param element Element under the pointer. + * @returns Row and column, or null if not a pixel. + */ + private getPixelCoordsFromElement( + element: Element | null, + ): {r: number; c: number} | null { + const pixelButton = element?.closest('.pixelButton'); + const rowIndex = pixelButton?.getAttribute('data-row'); + const colIndex = pixelButton?.getAttribute('data-col'); + if (rowIndex == null || colIndex == null) { + return null; + } + return {r: parseInt(rowIndex), c: parseInt(colIndex)}; + } + + /** + * Sets the specified pixel in the editor to the current value being painted. + * + * @param r Row number of grid. + * @param c Column number of grid. + */ + private updatePixelValue(r: number, c: number) { + if ( + this.valToPaintWith !== undefined && + this.getPixel(r, c) !== this.valToPaintWith + ) { + this.setPixel(r, c, this.valToPaintWith); + } + } + + /** + * Resets pointer state (e.g. After either a pointerup event or if the + * gesture is canceled). + * + * @param e The pointer event that ended the gesture. + */ + private onPointerEnd(e: PointerEvent) { + if ( + e.currentTarget instanceof HTMLElement && + e.currentTarget.hasPointerCapture?.(e.pointerId) + ) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + this.pointerIsDown = false; + this.valToPaintWith = undefined; + } + + /** + * Sets all the pixels in the image to a random value. + */ + private randomizePixels() { + const getRandBinary = () => Math.floor(Math.random() * 2); + this.forAllCells((r, c) => { + this.setPixel(r, c, getRandBinary()); + }); + } + + /** + * Sets all the pixels to 0. + */ + private clearPixels() { + const cleared = this.getEmptyArray(); + this.fireIntermediateChangeEvent(cleared); + this.setValue(cleared, false); + this.forAllCells((r, c) => { + this.updateEditorPixelDisplay(r, c, 0); + }); + } + + /** + * Sets the value of a particular pixel. + * + * @param r Row number of grid. + * @param c Column number of grid. + * @param newValue Value of the pixel. + */ + private setPixel(r: number, c: number, newValue: number) { + const newGrid = JSON.parse(JSON.stringify(this.getValue())); + newGrid[r][c] = newValue; + this.fireIntermediateChangeEvent(newGrid); + this.setValue(newGrid, false); + this.updateEditorPixelDisplay(r, c, newValue); + } + + private getPixel(row: number, column: number): number { + const value = this.getValue(); + if (!value) { + throw new Error( + 'Attempted to retrieve a pixel value when no value is set', + ); + } + + return value[row][column]; + } + + /** + * Calls a given function for all cells in the image, with the cell + * coordinates as the arguments. + * + * @param func A function to be applied. + */ + private forAllCells(func: (row: number, col: number) => void) { + for (let r = 0; r < this.imgHeight; r++) { + for (let c = 0; c < this.imgWidth; c++) { + func(r, c); + } + } + } + + /** + * Creates a new element with the specified type and class. + * + * @param elementType Type of html element. + * @param className ClassName of html element. + * @returns The created element. + */ + private createElementWithClassname(elementType: string, className: string) { + const newElt = document.createElement(elementType); + newElt.className = className; + return newElt; + } + + /** + * Binds an event listener to the specified element. + * + * @param element Specified element. + * @param eventName Name of the event to bind. + * @param callback Function to be called on specified event. + */ + private bindEvent( + element: HTMLElement, + eventName: string, + callback: (e: E) => void, + ) { + this.boundEvents.push( + Blockly.browserEvents.bind(element, eventName, this, callback), + ); + } + + private fireIntermediateChangeEvent(newValue: number[][]) { + if (this.getSourceBlock()) { + Blockly.Events.fire( + new (Blockly.Events.get( + Blockly.Events.BLOCK_FIELD_INTERMEDIATE_CHANGE, + ))(this.getSourceBlock(), this.name || null, this.getValue(), newValue), + ); + } + } +} + +interface Buttons { + readonly randomize: boolean; + readonly clear: boolean; +} +interface PixelColours { + readonly empty: string; + readonly filled: string; +} + +export interface FieldBitmapFromJsonConfig extends Blockly.FieldConfig { + value?: number[][]; + width?: number; + height?: number; + buttons?: Buttons; + fieldHeight?: number; + colours?: PixelColours; +} + +Blockly.fieldRegistry.register('field_bitmap', FieldBitmap); + +/** + * CSS for bitmap field. + */ +Blockly.Css.register(` +.dropdownEditor { + align-items: center; + flex-direction: column; + display: flex; + justify-content: center; +} +.dropdownEditor.has-buttons { + margin-bottom: 20px; +} +.bitmapPixelGrid { + display: grid; + margin: 20px; + grid-template-columns: repeat(var(--bitmap-columns), min-content); +} +.bitmapPixelRow { + display: contents; +} +.bitmapPixelGrid [role="gridcell"] { + padding: 0; + margin: 0; + line-height: 0; +} +.pixelButton { + width: ${DEFAULT_PIXEL_SIZE}px; + height: ${DEFAULT_PIXEL_SIZE}px; + border: 1px solid #000; + cursor: pointer; +} +.pixelButton:focus { + outline: var(--blockly-selection-width) solid var(--blockly-active-node-color); + outline-offset: -2px; + box-shadow: none; + position: relative; + z-index: 1; +} +.controlButton { + margin: 5px 0; +} +.blocklyDropDownContent.contains-bitmap-editor { + max-height: none; +} +`); diff --git a/packages/plugins/field-bitmap/src/index.ts b/packages/plugins/field-bitmap/src/index.ts new file mode 100644 index 00000000000..1e196ea9b27 --- /dev/null +++ b/packages/plugins/field-bitmap/src/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './field-bitmap'; diff --git a/packages/plugins/field-bitmap/test/field_test.mocha.js b/packages/plugins/field-bitmap/test/field_test.mocha.js new file mode 100644 index 00000000000..d809bb21b22 --- /dev/null +++ b/packages/plugins/field-bitmap/test/field_test.mocha.js @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const {testHelpers} = require('@blockly/dev-tools'); +const {assert} = require('chai'); +const {FieldBitmap, DEFAULT_HEIGHT, DEFAULT_WIDTH} = require('../src/index'); + +/** + * Helper method to reformat raw test cases to a format that the + * various tests can use. + * @param {!Array} testCases raw testcases that + * need to be reformatted + * @returns {!Array} the reformatted test cases + * that can be used by various tests + */ +function processTestCases(testCases) { + return testCases.map((x) => ({ + title: x.title, + value: x.value, + args: [x.value, null, {}], + json: {value: x.value}, + })); +} + +const { + FieldCreationTestCase, + FieldValueTestCase, + runConstructorSuiteTests, + runFromJsonSuiteTests, +} = testHelpers; + +suite('FieldBitmap', function () { + /** + * Configuration for field tests with invalid values. + * @type {Array} + */ + const invalidValueTestCases = processTestCases([ + {title: 'Undefined', value: undefined}, + {title: 'Null', value: null}, + {title: 'NaN', value: NaN}, + {title: 'Non-Parsable String', value: 'bad'}, + {title: 'Not an array', value: 1}, + {title: 'Not a 2D array', value: [0, 1, 0, 1]}, + { + title: 'Not a rectangle', + value: [ + [1, 1, 1], + [1, 1, 1, 1, 1, 1], + [1, 1, 1], + ], + }, + { + title: 'Contains non-binary number', + value: [ + [1, 99, 1], + [1, 1, 1], + [1, 1, 1], + ], + }, + { + title: 'Contains bad value', + value: [ + [1, 'b', 1], + [1, 1, 1], + [1, 1, 1], + ], + }, + ]); + + /** + * Configuration for field tests with valid values. + * @type {Array} + */ + const validValueTestCases = processTestCases([ + { + title: '3x3 solid', + value: [ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1], + ], + }, + { + title: '4x4 solid', + value: [ + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1], + ], + }, + { + title: '3x3 checkerboard', + value: [ + [1, 0, 1], + [0, 1, 0], + [1, 0, 1], + ], + }, + { + title: '4x4 empty', + value: [ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + }, + ]); + + /** + * The expected default value for the field being tested. + * @type {*} + */ + const defaultFieldValue = [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ]; + /** + * Asserts that the field property values are set to default. + * @param {FieldBitmap} field The field to check. + */ + const assertFieldDefault = function (field) { + assert.deepEqual(field.getValue(), defaultFieldValue); + }; + /** + * Asserts that the field properties are correct based on the test case. + * @param {FieldBitmap} field The field to check. + * @param {FieldValueTestCase} testCase The test case. + */ + const validTestCaseAssertField = function (field, testCase) { + const expectedValue = testCase.value; + assert.deepEqual(field.getValue(), expectedValue); + assert.equal(field.getImageHeight(), expectedValue.length); + assert.equal(field.getImageWidth(), expectedValue[0].length); + }; + + /** + * Reimplementation of the function from TestHelpers that supports array + * equality testing. + * @param {*} validValueTestCases test cases for valid field values + * @param {*} invalidValueTestCases test cases for invalid field values + */ + function runSetValueTests(validValueTestCases, invalidValueTestCases) { + const createFieldForTestCase = (testCase) => + FieldBitmap.fromJson(`{ + width: ${ + Array.isArray(testCase.value) ? testCase.value.length : DEFAULT_WIDTH + }, + height:${ + testCase.value && Array.isArray(testCase.value[0]) + ? testCase.value[0].length + : DEFAULT_HEIGHT + }, + }`); + + /** + * Creates test callback for invalid setValue test. + * @param {!FieldValueTestCase} testCase The test case information. + * @returns {!Function} The test callback. + */ + const createInvalidSetValueTestCallback = (testCase) => { + return function () { + const field = createFieldForTestCase(testCase); + field.setValue(testCase.value); + assertFieldDefault(field); + }; + }; + /** + * Creates test callback for valid setValue test. + * @param {!FieldValueTestCase} testCase The test case information. + * @returns {!Function} The test callback. + */ + const createValidSetValueTestCallback = (testCase) => { + return function () { + const field = createFieldForTestCase(testCase); + field.setValue(testCase.value); + validTestCaseAssertField(field, testCase); + }; + }; + + testHelpers.runTestCases( + invalidValueTestCases, + createInvalidSetValueTestCallback, + ); + testHelpers.runTestCases( + validValueTestCases, + createValidSetValueTestCallback, + ); + } + + runConstructorSuiteTests( + FieldBitmap, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + runFromJsonSuiteTests( + FieldBitmap, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + runSetValueTests(validValueTestCases, invalidValueTestCases); +}); diff --git a/packages/plugins/field-bitmap/test/index.html b/packages/plugins/field-bitmap/test/index.html new file mode 100644 index 00000000000..10cd0a0c1da --- /dev/null +++ b/packages/plugins/field-bitmap/test/index.html @@ -0,0 +1,18 @@ + + + + + Blockly Field Test + + + + +
+ + + diff --git a/packages/plugins/field-bitmap/test/index.js b/packages/plugins/field-bitmap/test/index.js new file mode 100644 index 00000000000..8eef0a91a7b --- /dev/null +++ b/packages/plugins/field-bitmap/test/index.js @@ -0,0 +1,336 @@ +/** + * @license + * Copyright 2021 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Field test. + */ + +import '../src/field-bitmap'; + +import {createPlayground} from '@blockly/dev-tools'; +import * as Blockly from 'blockly'; + +/** + * An array of blocks that are defined only for the purposes of + * manually and visually testing the bitmap field. + */ +const testBlockDefinitions = [ + { + type: 'test_standard_field_values', + message0: '%1', + args0: [ + { + type: 'field_bitmap', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_bitmap`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values', + message0: '%1', + args0: [ + { + type: 'field_bitmap', + name: 'FIELDNAME', + value: [ + [1, 0, 1], + [0, 1, 0], + [0, 0, 0], + ], + width: 3, + height: 3, + alt: { + type: 'field_label', + text: `No field_bitmap`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_standard_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_bitmap', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_bitmap`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_bitmap', + name: 'FIELDNAME', + value: [ + [1, 0, 1], + [0, 1, 0], + [0, 0, 0], + ], + width: 3, + height: 3, + alt: { + type: 'field_label', + text: `No field_bitmap`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_colours_no_buttons', + message0: '%1', + args0: [ + { + type: 'field_bitmap', + name: 'FIELDNAME', + value: [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + fieldHeight: 50, + buttons: { + randomize: false, + clear: false, + }, + colours: { + filled: '#4888f4', + empty: '#cad2dc', + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_colours_no_buttons_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_bitmap', + name: 'FIELDNAME', + value: [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + fieldHeight: 50, + buttons: { + randomize: false, + clear: false, + }, + colours: { + filled: '#4888f4', + empty: '#cad2dc', + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_parent_block', + message0: 'parent %1', + args0: [ + { + type: 'input_value', + name: 'INPUT', + }, + ], + previousStatement: null, + nextStatement: null, + style: 'loop_blocks', + }, +]; + +Blockly.defineBlocksWithJsonArray(testBlockDefinitions); + +/** + * A test toolbox containing the exported blocks and a variety of + * test blocks to exercise the bitmap field in different contexts + * (on a shadow block, as the only field on a block, etc). + * These are in a simple toolbox, rather than a category toolbox, so that + * they are all instantiated every time the test page is opened. + */ +const jsonToolbox = { + contents: [ + { + kind: 'label', + text: 'Test blocks: default field values', + }, + { + kind: 'block', + type: 'test_standard_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_standard_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values_and_label', + }, + }, + }, + }, + { + kind: 'label', + text: 'Test blocks: custom field values', + }, + { + kind: 'block', + type: 'test_custom_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_custom_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values_and_label', + }, + }, + }, + }, + { + kind: 'label', + text: 'Test blocks: Static field height, custom colours, no buttons', + }, + { + kind: 'block', + type: 'test_custom_colours_no_buttons', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_colours_no_buttons', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_custom_colours_no_buttons_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_colours_no_buttons_and_label', + }, + }, + }, + }, + ], +}; + +/** + * Create a workspace. + * @param {HTMLElement} blocklyDiv The blockly container div. + * @param {!Blockly.BlocklyOptions} options The Blockly options. + * @returns {!Blockly.WorkspaceSvg} The created workspace. + */ +function createWorkspace(blocklyDiv, options) { + const workspace = Blockly.inject(blocklyDiv, options); + return workspace; +} + +document.addEventListener('DOMContentLoaded', function () { + const defaultOptions = { + toolbox: jsonToolbox, + }; + createPlayground( + document.getElementById('root'), + createWorkspace, + defaultOptions, + ); +}); diff --git a/packages/plugins/field-bitmap/tsconfig.json b/packages/plugins/field-bitmap/tsconfig.json new file mode 100644 index 00000000000..99858c525f1 --- /dev/null +++ b/packages/plugins/field-bitmap/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "es2015", + "moduleResolution": "bundler", + "target": "es6", + "strict": true + }, + // NOTE: `test/**/*` is automatically included in `blockly-scripts start`. + // Only src matters for production builds. + "include": ["src"] +} diff --git a/packages/plugins/field-colour-hsv-sliders/CHANGELOG.md b/packages/plugins/field-colour-hsv-sliders/CHANGELOG.md new file mode 100644 index 00000000000..64a51a089bc --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/CHANGELOG.md @@ -0,0 +1,673 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [6.0.12](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.11...@blockly/field-colour-hsv-sliders@6.0.12) (2026-04-09) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.11](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.10...@blockly/field-colour-hsv-sliders@6.0.11) (2026-01-15) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.10](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.9...@blockly/field-colour-hsv-sliders@6.0.10) (2026-01-05) + + +### Bug Fixes + +* update plugin URLs to RPF ([#2665](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2665)) ([6c34039](https://github.com/RaspberryPiFoundation/blockly-samples/commit/6c3403960b8070eb8e576bd5702bdb53a09ac475)) + + + +## [6.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.8...@blockly/field-colour-hsv-sliders@6.0.9) (2026-01-05) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.7...@blockly/field-colour-hsv-sliders@6.0.8) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.6...@blockly/field-colour-hsv-sliders@6.0.7) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.5...@blockly/field-colour-hsv-sliders@6.0.6) (2025-10-30) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.4...@blockly/field-colour-hsv-sliders@6.0.5) (2025-10-02) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.3...@blockly/field-colour-hsv-sliders@6.0.4) (2025-07-17) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.2...@blockly/field-colour-hsv-sliders@6.0.3) (2025-07-03) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.1...@blockly/field-colour-hsv-sliders@6.0.2) (2025-06-19) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@6.0.0...@blockly/field-colour-hsv-sliders@6.0.1) (2025-05-22) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [6.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.19...@blockly/field-colour-hsv-sliders@6.0.0) (2025-05-16) + + +### ⚠ BREAKING CHANGES + +* update all plugins to blockly v12 (#2538) + +### Features + +* update all plugins to blockly v12 ([#2538](https://github.com/google/blockly-samples/issues/2538)) ([420ba31](https://github.com/google/blockly-samples/commit/420ba3124b260b2c06e8e1d61be44e870780d95f)) + + + +## [5.0.19](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.18...@blockly/field-colour-hsv-sliders@5.0.19) (2025-05-16) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.18](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.17...@blockly/field-colour-hsv-sliders@5.0.18) (2025-05-15) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.16...@blockly/field-colour-hsv-sliders@5.0.17) (2025-05-08) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.15...@blockly/field-colour-hsv-sliders@5.0.16) (2025-03-27) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.14...@blockly/field-colour-hsv-sliders@5.0.15) (2025-02-13) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.13...@blockly/field-colour-hsv-sliders@5.0.14) (2025-01-23) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.12...@blockly/field-colour-hsv-sliders@5.0.13) (2024-12-19) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.11...@blockly/field-colour-hsv-sliders@5.0.12) (2024-12-03) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.10...@blockly/field-colour-hsv-sliders@5.0.11) (2024-11-07) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.9...@blockly/field-colour-hsv-sliders@5.0.10) (2024-09-26) + + +### Bug Fixes + +* **field-*:** Remove unneeded `fieldRegistry.unregister` calls ([#2454](https://github.com/google/blockly-samples/issues/2454)) ([b3ba30e](https://github.com/google/blockly-samples/commit/b3ba30e23dddf0bd98c266659aa229ba6ba685b0)), closes [#2453](https://github.com/google/blockly-samples/issues/2453) + + + + + +## [5.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.8...@blockly/field-colour-hsv-sliders@5.0.9) (2024-08-29) + + +### Bug Fixes + +* predeploy plugins ([#2449](https://github.com/google/blockly-samples/issues/2449)) ([6b36d8b](https://github.com/google/blockly-samples/commit/6b36d8b344a969f79d89bbc7dcee29ae554759f9)) + + + + + +## [5.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.7...@blockly/field-colour-hsv-sliders@5.0.8) (2024-08-22) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.6...@blockly/field-colour-hsv-sliders@5.0.7) (2024-08-08) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.5...@blockly/field-colour-hsv-sliders@5.0.6) (2024-08-01) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.4...@blockly/field-colour-hsv-sliders@5.0.5) (2024-07-25) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.3...@blockly/field-colour-hsv-sliders@5.0.4) (2024-07-11) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.2...@blockly/field-colour-hsv-sliders@5.0.3) (2024-07-04) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.1...@blockly/field-colour-hsv-sliders@5.0.2) (2024-06-06) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [5.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@5.0.0...@blockly/field-colour-hsv-sliders@5.0.1) (2024-05-30) + + +### Bug Fixes + +* multiple blockly instances ([#2375](https://github.com/google/blockly-samples/issues/2375)) ([b231e59](https://github.com/google/blockly-samples/commit/b231e598f2f5f5b0abbfd01d981e35572ad50a26)) + + + +## [5.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.2.7...@blockly/field-colour-hsv-sliders@5.0.0) (2024-05-21) + + +### ⚠ BREAKING CHANGES + +* update all plugins to v11 of blockly + +### Features + +* update all plugins to v11 of blockly ([5fcd3e7](https://github.com/google/blockly-samples/commit/5fcd3e7d53eaadffe9bda9a378b404d34b2f8be2)) +* upgrade ts version, fix field validators ([b2d2191](https://github.com/google/blockly-samples/commit/b2d2191ff11b30347b33f95acf6e58f7ce54d004)) + + +### Bug Fixes + +* Call blocks handle both manual disabling and disabled defs ([#2334](https://github.com/google/blockly-samples/issues/2334)) ([5eade55](https://github.com/google/blockly-samples/commit/5eade55779c4022d14ad4472ff32c93c78199887)) +* remove some uses of generateFieldTextBlocks ([#2357](https://github.com/google/blockly-samples/issues/2357)) ([53b3fa4](https://github.com/google/blockly-samples/commit/53b3fa4604aabb1fc5240d41c8d1d389d4b15301)) + + + +## [4.2.7](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.2.6...@blockly/field-colour-hsv-sliders@4.2.7) (2024-05-16) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.2.6](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.2.5...@blockly/field-colour-hsv-sliders@4.2.6) (2024-05-09) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.2.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.2.4...@blockly/field-colour-hsv-sliders@4.2.5) (2024-04-25) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.2.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.2.3...@blockly/field-colour-hsv-sliders@4.2.4) (2024-04-11) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.2.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.2.2...@blockly/field-colour-hsv-sliders@4.2.3) (2024-04-04) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.2.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.2.1...@blockly/field-colour-hsv-sliders@4.2.2) (2024-03-28) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.2.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.2.0...@blockly/field-colour-hsv-sliders@4.2.1) (2024-02-08) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.2.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.9...@blockly/field-colour-hsv-sliders@4.2.0) (2024-02-01) + + +### Features + +* Use intermediate change events for HSV sliders. ([#2173](https://github.com/google/blockly-samples/issues/2173)) ([ed56b79](https://github.com/google/blockly-samples/commit/ed56b79be5507790572000f028a49d7bf317896d)) + + + +## [4.1.9](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.8...@blockly/field-colour-hsv-sliders@4.1.9) (2023-12-14) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.1.8](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.7...@blockly/field-colour-hsv-sliders@4.1.8) (2023-12-07) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.1.7](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.6...@blockly/field-colour-hsv-sliders@4.1.7) (2023-11-09) + + +### Bug Fixes + +* add types for filed-colour-hsv-sliders ([#2063](https://github.com/google/blockly-samples/issues/2063)) ([e630704](https://github.com/google/blockly-samples/commit/e6307043f812624e00207084815554c5cebe4dc9)) + + + +## [4.1.6](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.5...@blockly/field-colour-hsv-sliders@4.1.6) (2023-11-02) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.1.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.4...@blockly/field-colour-hsv-sliders@4.1.5) (2023-10-30) + + +### Bug Fixes + +* **packaging:** Don't advertise non-existent ESM entrypoints ([#2022](https://github.com/google/blockly-samples/issues/2022)) ([d019198](https://github.com/google/blockly-samples/commit/d0191984399b784e2928b8fb4c58257bfa857655)), closes [#1877](https://github.com/google/blockly-samples/issues/1877) +* update tsconfigs to publish types accurately ([df4b084](https://github.com/google/blockly-samples/commit/df4b0844af712f5025a2ec842458b828f3147676)) +* use includes rather than excludes ([40efc25](https://github.com/google/blockly-samples/commit/40efc255329e3ca476ccc247b95a2d05dd77b45e)) + + + +## [4.1.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.3...@blockly/field-colour-hsv-sliders@4.1.4) (2023-09-21) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.1.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.2...@blockly/field-colour-hsv-sliders@4.1.3) (2023-09-14) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.1.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.1...@blockly/field-colour-hsv-sliders@4.1.2) (2023-08-31) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.1.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.1.0...@blockly/field-colour-hsv-sliders@4.1.1) (2023-08-17) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.1.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.0.2...@blockly/field-colour-hsv-sliders@4.1.0) (2023-08-03) + + +### Features + +* Update the field-colour-hsv-sliders package to use the field-colour package. ([#1830](https://github.com/google/blockly-samples/issues/1830)) ([e756bf4](https://github.com/google/blockly-samples/commit/e756bf488c5cf094e6fd236b70482b6bc23cae4f)) + + + +## [4.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.0.1...@blockly/field-colour-hsv-sliders@4.0.2) (2023-07-24) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@4.0.0...@blockly/field-colour-hsv-sliders@4.0.1) (2023-07-20) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [4.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@3.0.1...@blockly/field-colour-hsv-sliders@4.0.0) (2023-07-13) + + +### ⚠ BREAKING CHANGES + +* updated `plugins/field-date` to TypeScript (#1705) + +### Features + +* updated `plugins/field-date` to TypeScript ([#1705](https://github.com/google/blockly-samples/issues/1705)) ([e5531ff](https://github.com/google/blockly-samples/commit/e5531fffe188ee361a16fe48ed126b34e51a8d30)) + + + +## [3.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@3.0.0...@blockly/field-colour-hsv-sliders@3.0.1) (2023-07-06) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [3.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.1.5...@blockly/field-colour-hsv-sliders@3.0.0) (2023-06-28) + + +### ⚠ BREAKING CHANGES + +* update all plugins to use blockly-v10.0.0 (#1744) + +### Features + +* update all plugins to use blockly-v10.0.0 ([#1744](https://github.com/google/blockly-samples/issues/1744)) ([6cc88cb](https://github.com/google/blockly-samples/commit/6cc88cbef39d4ad664a668d3d46eb29ba7292f9c)) + + +### Bug Fixes + +* renaming build problems ([7087469](https://github.com/google/blockly-samples/commit/7087469ccdaf27e040cd67a386c9284aa6b1cd67)) +* update all usages of generators in script tags for blockly v10 ([#1734](https://github.com/google/blockly-samples/issues/1734)) ([19faf29](https://github.com/google/blockly-samples/commit/19faf29428e3903a60da7c2679818cf8c63b7309)) + + + +## [2.1.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.1.4...@blockly/field-colour-hsv-sliders@2.1.5) (2023-06-22) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.1.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.1.3...@blockly/field-colour-hsv-sliders@2.1.4) (2023-05-18) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.1.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.1.2...@blockly/field-colour-hsv-sliders@2.1.3) (2023-05-11) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.1.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.1.1...@blockly/field-colour-hsv-sliders@2.1.2) (2023-05-04) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.1.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.1.0...@blockly/field-colour-hsv-sliders@2.1.1) (2023-04-27) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.1.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.12...@blockly/field-colour-hsv-sliders@2.1.0) (2023-03-23) + + +### Features + +* Create plugin for dependent dropdown fields. ([#1555](https://github.com/google/blockly-samples/issues/1555)) ([1c473d6](https://github.com/google/blockly-samples/commit/1c473d6b2c4aa3754cf332c07585af60342537ef)) + + + +## [2.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.11...@blockly/field-colour-hsv-sliders@2.0.12) (2023-03-09) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.10...@blockly/field-colour-hsv-sliders@2.0.11) (2023-03-02) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.9...@blockly/field-colour-hsv-sliders@2.0.10) (2023-02-23) + + +### Bug Fixes + +* remove prepublishOnly scripts ([#1579](https://github.com/google/blockly-samples/issues/1579)) ([27da6cd](https://github.com/google/blockly-samples/commit/27da6cd04c38f6ba417f4e7446bb6218c475448d)) + + + +## [2.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.8...@blockly/field-colour-hsv-sliders@2.0.9) (2023-02-23) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.7...@blockly/field-colour-hsv-sliders@2.0.8) (2023-02-16) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.6...@blockly/field-colour-hsv-sliders@2.0.7) (2023-02-09) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.5...@blockly/field-colour-hsv-sliders@2.0.6) (2023-01-26) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.4...@blockly/field-colour-hsv-sliders@2.0.5) (2023-01-12) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.3...@blockly/field-colour-hsv-sliders@2.0.4) (2023-01-05) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.2...@blockly/field-colour-hsv-sliders@2.0.3) (2022-12-15) + +**Note:** Version bump only for package @blockly/field-colour-hsv-sliders + + + + + +## [2.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.1...@blockly/field-colour-hsv-sliders@2.0.2) (2022-10-27) + + +### Bug Fixes + +* disable eye dropper instead of using fallback ([#1337](https://github.com/google/blockly-samples/issues/1337)) ([7fb03d9](https://github.com/google/blockly-samples/commit/7fb03d94346ada497d0822df00364c38ea9bd4ff)) + + + +## [2.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@2.0.0...@blockly/field-colour-hsv-sliders@2.0.1) (2022-10-13) + + +### Bug Fixes + +* Reverting invalid changes to hsv slider css. ([#1329](https://github.com/google/blockly-samples/issues/1329)) ([925858d](https://github.com/google/blockly-samples/commit/925858dd1ab7befcf051bf64d045dc71ea4ed997)) + + + +## [2.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour-hsv-sliders@1.1.0...@blockly/field-colour-hsv-sliders@2.0.0) (2022-10-05) + + +### ⚠ BREAKING CHANGES + +* update peer and devDependencies of all plugins to require Blockly v9 (#1314) + +### Bug Fixes + +* Added css documentation for workspace search plugin. ([#1282](https://github.com/google/blockly-samples/issues/1282)) ([5dd7d2d](https://github.com/google/blockly-samples/commit/5dd7d2d2bed3d1e4920e27b795a06fff08e85297)) +* references to deprecated functions in v9 ([#1313](https://github.com/google/blockly-samples/issues/1313)) ([cb2e679](https://github.com/google/blockly-samples/commit/cb2e67987e0b62a77c26adc660cc6ade1ba67954)) + + +### Miscellaneous Chores + +* update peer and devDependencies of all plugins to require Blockly v9 ([#1314](https://github.com/google/blockly-samples/issues/1314)) ([03d4912](https://github.com/google/blockly-samples/commit/03d4912c42c8de0f30493037ccc28dddaea0f266)) + + + +# 1.1.0 (2022-09-08) + + +### Features + +* created plugin for colour hsv sliders field. ([#1234](https://github.com/google/blockly-samples/issues/1234)) ([d46f28e](https://github.com/google/blockly-samples/commit/d46f28e50200eb060d75171077262c071481580d)) diff --git a/packages/plugins/field-colour-hsv-sliders/README.md b/packages/plugins/field-colour-hsv-sliders/README.md new file mode 100644 index 00000000000..0c4c589e61e --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/README.md @@ -0,0 +1,88 @@ +# @blockly/field-colour-hsv-sliders [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +A [Blockly](https://www.npmjs.com/package/blockly) colour field using HSV sliders for editing. + +![A Blockly workspace showing the HSV sliders.](readme-media/hsv_sliders_screenshot.png) + +## Installation + +### Yarn + +``` +yarn add @blockly/field-colour-hsv-sliders +``` + +### npm + +``` +npm install @blockly/field-colour-hsv-sliders --save +``` + +## Usage + +This plugin adds a field type `FieldColourHsvSliders` that is registered to the name `'field_colour_hsv_sliders'`. This field is an extension of the `Blockly.FieldColour` field and outputs values in the same hexadecimal string format `'#rrggbb'` even though the UI represents the colour in the HSV colour space. See the [Blockly.FieldColour documentation](https://www.npmjs.com/package/@blockly/field-colour#creation) on what parameters and configurations this field supports, although unlike `Blockly.FieldColour`, this field does not use colour swatches and thus ignores options related to the swatches such as `'colourOptions'`. + +The UI also provides an eye dropper button that can be used to take a colour from anywhere on the screen [on supported platforms](https://caniuse.com/?search=eyedropper). Chrome, Edge, and Opera support the eye dropper feature, except [on Chromebooks](https://bugs.chromium.org/p/chromium/issues/detail?id=1348921) and on mobile devices. There is also [a known bug in Chrome on some versions of Linux](https://bugs.chromium.org/p/chromium/issues/detail?id=1227633) where the eye dropper may be visible but does not work correctly. + +To use it, you'll need to add this field to a block type definition, and add that block to your toolbox. See below for an example of defining a block type that uses this field. + +### JSON + +```js +import * as Blockly from 'blockly'; +import '@blockly/field-colour-hsv-sliders'; +import {javascriptGenerator, Order} from 'blockly/javascript'; + +Blockly.defineBlocksWithJsonArray([ + { + type: 'colour_hsv_sliders', + message0: 'hsv %1', + args0: [ + { + type: 'field_colour_hsv_sliders', + name: 'COLOUR', + colour: '#ff0000', + }, + ], + output: 'Colour', + style: 'colour_blocks', + }, +]); + +javascriptGenerator.forBlock['colour_hsv_sliders'] = function ( + block, + generator, +) { + const code = generator.quote_(block.getFieldValue('COLOUR')); + return [code, Order.ATOMIC]; +}; +``` + +### JavaScript + +```js +import * as Blockly from 'blockly'; +import {FieldColourHsvSliders} from '@blockly/field-colour-hsv-sliders'; +import {javascriptGenerator, Order} from 'blockly/javascript'; + +Blockly.Blocks['colour_hsv_sliders'] = { + init: function () { + this.appendDummyInput() + .appendField('hsv ') + .appendField(new FieldColourHsvSliders('#ff0000'), 'COLOUR'); + this.setOutput(true, 'Colour'); + this.setStyle('colour_blocks'); + }, +}; +javascriptGenerator.forBlock['colour_hsv_sliders'] = function ( + block, + generator, +) { + const code = generator.quote_(block.getFieldValue('COLOUR')); + return [code, Order.ATOMIC]; +}; +``` + +## License + +Apache 2.0 diff --git a/packages/plugins/field-colour-hsv-sliders/package.json b/packages/plugins/field-colour-hsv-sliders/package.json new file mode 100644 index 00000000000..d710e04c5b2 --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/package.json @@ -0,0 +1,60 @@ +{ + "name": "@blockly/field-colour-hsv-sliders", + "version": "13.1.0", + "private": true, + "description": "A Blockly colour field using HSV sliders.", + "scripts": { + "audit:fix": "blockly-scripts auditFix", + "build": "blockly-scripts build", + "clean": "blockly-scripts clean", + "lint": "eslint .", + "predeploy": "blockly-scripts predeploy", + "start": "blockly-scripts start", + "test": "blockly-scripts test" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.js", + "author": "Blockly Team", + "keywords": [ + "blockly", + "colour", + "field", + "hsv", + "slider" + ], + "homepage": "https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/field-colour-hsv-sliders#readme", + "bugs": { + "url": "https://github.com/RaspberryPiFoundation/blockly/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaspberryPiFoundation/blockly.git", + "directory": "packages/plugins/field-colour-hsv-sliders" + }, + "license": "Apache-2.0", + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src" + ], + "dependencies": { + "@blockly/field-colour": "^6.0.12" + }, + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "jsdom": "^29.1.1", + "jsdom-global": "^3.0.2", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/field-colour-hsv-sliders/readme-media/hsv_sliders_screenshot.png b/packages/plugins/field-colour-hsv-sliders/readme-media/hsv_sliders_screenshot.png new file mode 100644 index 00000000000..409e5c6b80b Binary files /dev/null and b/packages/plugins/field-colour-hsv-sliders/readme-media/hsv_sliders_screenshot.png differ diff --git a/packages/plugins/field-colour-hsv-sliders/src/field_colour_hsv_sliders.ts b/packages/plugins/field-colour-hsv-sliders/src/field_colour_hsv_sliders.ts new file mode 100644 index 00000000000..2d6b8195f3f --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/src/field_colour_hsv_sliders.ts @@ -0,0 +1,864 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Colour input field with HSV sliders. + */ + +import * as Blockly from 'blockly/core'; +import {FieldColour} from '@blockly/field-colour'; + +// Experimental API: https://developer.mozilla.org/en-US/docs/Web/API/EyeDropper +declare interface EyeDropper { + open: () => Promise<{sRGBHex: string}>; +} +declare global { + interface Window { + EyeDropper?: {new (): EyeDropper}; + } +} + +/** + * A structure with three properties r, g, and b, representing the amount of + * red, green, and blue light in the sRGB colour space where 1 is the maximum + * amount of light that can be displayed. + */ +class RgbColour { + /** The red component of the colour, ranging from 0 to 1. */ + r: number; + + /** The green component of the colour, ranging from 0 to 1. */ + g: number; + + /** The blue component of the colour, ranging from 0 to 1. */ + b: number; + + /** + * The RgbColour constructor. + * + * @param r The initial amount of red. Defaults to 0. + * @param g The initial amount of green. Defaults to 0. + * @param b The initial amount of blue. Defaults to 0. + */ + constructor(r = 0, g = 0, b = 0) { + this.r = r; + this.g = g; + this.b = b; + } + + /** + * Given a number from 0 to 1, returns a two-digit hexadecimal string from + * '00' to 'ff'. + * + * @param x The amount of light in a component from 0 to 1. + * @returns A hexadecimal representation from '00' to 'ff'. + */ + static componentToHex(x: number): string { + if (x <= 0) return '00'; + if (x >= 1) return 'ff'; + return ('0' + ((x * 255 + 0.5) >>> 0).toString(16)).slice(-2); + } + + /** + * Returns a hexadecimal string in the format #rrggbb representing the colour. + * + * @returns A hexadecimal representation of this colour. + */ + toHex(): string { + return ( + '#' + + RgbColour.componentToHex(this.r) + + RgbColour.componentToHex(this.g) + + RgbColour.componentToHex(this.b) + ); + } + + /** + * Updates the properties of this instance to represent the same colour as the + * provided string in the hexadecimal format #rrggbb. + * + * @param hex A hexadecimal string in the format '#rrggbb'. + * @returns This instance after updating it. + */ + loadFromHex(hex: string): RgbColour { + this.r = parseInt(hex.slice(1, 3), 16) / 255; + this.g = parseInt(hex.slice(3, 5), 16) / 255; + this.b = parseInt(hex.slice(5, 7), 16) / 255; + return this; + } + + /** + * Updates the properties of this instance to represent the same colour as the + * provided HsvColour but in the sRGB colour space. + * + * @param hsv An HSV representation of a colour to copy. + * @returns This instance after updating it. + */ + loadFromHsv(hsv: HsvColour): RgbColour { + const hue: number = (hsv.h - Math.floor(hsv.h)) * 6; + this.r = + hsv.v * (1 - hsv.s * Math.max(0, Math.min(1, 2 - Math.abs(hue - 3)))); + this.g = + hsv.v * (1 - hsv.s * Math.max(0, Math.min(1, Math.abs(hue - 2) - 1))); + this.b = + hsv.v * (1 - hsv.s * Math.max(0, Math.min(1, Math.abs(hue - 4) - 1))); + return this; + } +} + +/** + * A structure with three properties h, s, and v, representing the hue, + * saturation, and brightness in a colour. All three properties range from 0 + * to 1. + */ +class HsvColour { + /** The hue of the colour, ranging from 0 to 1. */ + h: number; + + /** The saturation of the colour, ranging from 0 to 1. */ + s: number; + + /** The brightness of the colour, ranging from 0 to 1. */ + v: number; + + /** + * The HsvColour constructor. + * + * @param h The initial hue of the colour. Defaults to 0. + * @param s The initial amount of saturation. Defaults to 0. + * @param v The initial amount of brightness. Defaults to 0. + */ + constructor(h = 0, s = 0, v = 0) { + this.h = h; + this.s = s; + this.v = v; + } + + /** + * Updates the properties of this instance to represent the same colour as the + * provided RgbColour but in the HSV colour space. + * + * @param rgb An RGB representation of a colour to copy. + * @returns This instance after updating it. + */ + loadFromRgb(rgb: RgbColour): HsvColour { + const max: number = Math.max(Math.max(rgb.r, rgb.g), rgb.b); + const min: number = Math.min(Math.min(rgb.r, rgb.g), rgb.b); + this.v = max; + if (min === max) { + this.h = 0; + this.s = 0; + return this; + } + + const delta: number = max - min; + this.s = delta / max; + + let hue: number; + if (rgb.r === max) { + hue = (rgb.g - rgb.b) / delta; + } else if (rgb.g === max) { + hue = 2 + (rgb.b - rgb.r) / delta; + } else { + hue = 4 + (rgb.r - rgb.g) / delta; + } + hue /= 6; + this.h = hue - Math.floor(hue); + return this; + } + + /** + * Updates the properties of this instance to copy the provided HsvColour. + * + * @param other An HSV representation of a colour to copy. + * @returns This instance after updating it. + */ + copy(other: HsvColour): HsvColour { + this.h = other.h; + this.s = other.s; + this.v = other.v; + return this; + } +} + +/** + * Class for a colour input field that displays HSV slider widgets when clicked. + */ +export class FieldColourHsvSliders extends FieldColour { + /** The maximum value of the hue slider range. */ + private static readonly HUE_SLIDER_MAX = 360; + + /** The maximum value of the saturation slider range. */ + private static readonly SATURATION_SLIDER_MAX = 100; + + /** The maximum value of the brightness slider range. */ + private static readonly BRIGHTNESS_SLIDER_MAX = 100; + + /** + * The gradient control point positions should align with the center of the + * slider thumb when the corresponding colour is selected. When the slider + * is at the minimum or maximum value, the distance of center of the thumb + * from the edge of the track will be the thumb's radius, so that's how far + * the minimum and maximum control points should be. + */ + static readonly THUMB_RADIUS = 12; + + /** Helper colour structures to allow manipulation in the HSV colour space. */ + private static readonly helperHsv: HsvColour = new HsvColour(); + + /** Helper colour structures to support conversion to the RGB colour space. */ + private static readonly helperRgb: RgbColour = new RgbColour(); + + /** + * The intial value of the field when the user opened an editor to change its + * value. When the editor is disposed, an event will be fired that uses this + * as the event's oldValue. + */ + protected valueWhenEditorWasOpened: string | null = null; + + /** Array holding info needed to unbind events. Used for disposing. */ + private hsvBoundEvents: Blockly.browserEvents.Data[] = []; + + /** HTML span element to display the current hue. */ + private hueReadout: HTMLSpanElement | null = null; + + /** HTML range input element for editing hue. */ + private hueSlider: HTMLInputElement | null = null; + + /** HTML span element to display the current saturation. */ + private saturationReadout: HTMLSpanElement | null = null; + + /** HTML range input element for editing saturation. */ + private saturationSlider: HTMLInputElement | null = null; + + /** HTML span element to display the current brightness. */ + private brightnessReadout: HTMLSpanElement | null = null; + + /** HTML range input element for editing brightness. */ + private brightnessSlider: HTMLInputElement | null = null; + + /** HTML div element containing all the labels and sliders. */ + private dropdownContainer: HTMLDivElement | null = null; + + /* eslint-disable @typescript-eslint/naming-convention */ + /** + * Create and show the colour field's editor. + * + * @override + */ + protected showEditor_(): void { + /* eslint-enable @typescript-eslint/naming-convention */ + this.createDropdownSliders(); + if (!this.dropdownContainer || !this.hueSlider) { + throw new Error('Failed to initialize the HSV sliders.'); + } + Blockly.DropDownDiv.getContentDiv().appendChild(this.dropdownContainer); + + Blockly.DropDownDiv.showPositionedByField( + this, + this.dropdownDisposeSliders.bind(this), + ); + + this.valueWhenEditorWasOpened = this.value_; + + // Focus so we can start receiving keyboard events. + setTimeout(() => { + this.hueSlider?.focus({preventScroll: true}); + }, 250); + } + + /** + * Creates a row with a slider label and a readout to display the slider + * value, appends it to the provided container, and returns the readout. + * + * @param name The display name of the slider. + * @param container Where the row will be inserted. + * @returns The readout, so that it can be updated. + */ + private static createLabelInContainer( + name: string, + container: HTMLElement, + ): HTMLSpanElement { + const label: HTMLDivElement = document.createElement('div'); + const labelText: HTMLSpanElement = document.createElement('span'); + const readout: HTMLSpanElement = document.createElement('span'); + label.classList.add('fieldColourSliderLabel'); + labelText.textContent = name; + label.appendChild(labelText); + label.appendChild(readout); + container.appendChild(label); + return readout; + } + + /** + * Creates a slider, appends it to the provided container, and returns it. + * + * @param max The maximum value of the slider. + * @param step The minimum step size of the slider. + * @param container Where the row slider be inserted. + * @returns The slider. + */ + private createSliderInContainer( + max: number, + step: number, + container: HTMLElement, + ): HTMLInputElement { + const slider: HTMLInputElement = document.createElement('input'); + slider.classList.add('fieldColourSlider'); + slider.type = 'range'; + slider.min = String(0); + slider.max = String(max); + slider.step = String(step); + slider.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + Blockly.DropDownDiv.hideIfOwner(this); + Blockly.getFocusManager().focusNode(this); + } else if (e.key === 'Escape') { + this.setValue(this.valueWhenEditorWasOpened); + } + }); + container.appendChild(slider); + return slider; + } + + /** Creates the colour picker slider editor and adds event listeners. */ + private createDropdownSliders(): void { + const container: HTMLDivElement = document.createElement('div'); + container.classList.add('fieldColourSliderContainer'); + + this.hueReadout = FieldColourHsvSliders.createLabelInContainer( + 'Hue', + container, + ); + this.hueSlider = this.createSliderInContainer( + FieldColourHsvSliders.HUE_SLIDER_MAX, + 2, + container, + ); + Blockly.utils.aria.setState( + this.hueSlider, + Blockly.utils.aria.State.LABEL, + 'Hue', + ); + this.saturationReadout = FieldColourHsvSliders.createLabelInContainer( + 'Saturation', + container, + ); + this.saturationSlider = this.createSliderInContainer( + FieldColourHsvSliders.SATURATION_SLIDER_MAX, + 1, + container, + ); + Blockly.utils.aria.setState( + this.saturationSlider, + Blockly.utils.aria.State.LABEL, + 'Saturation', + ); + this.brightnessReadout = FieldColourHsvSliders.createLabelInContainer( + 'Brightness', + container, + ); + this.brightnessSlider = this.createSliderInContainer( + FieldColourHsvSliders.BRIGHTNESS_SLIDER_MAX, + 1, + container, + ); + Blockly.utils.aria.setState( + this.brightnessSlider, + Blockly.utils.aria.State.LABEL, + 'Brightness', + ); + + this.hsvBoundEvents.push( + Blockly.browserEvents.conditionalBind( + this.hueSlider, + 'input', + this, + this.onSliderChange, + ), + ); + this.hsvBoundEvents.push( + Blockly.browserEvents.conditionalBind( + this.saturationSlider, + 'input', + this, + this.onSliderChange, + ), + ); + this.hsvBoundEvents.push( + Blockly.browserEvents.conditionalBind( + this.brightnessSlider, + 'input', + this, + this.onSliderChange, + ), + ); + + if (window.EyeDropper) { + // If the browser supports the eyedropper API, create a button for it. + const button: HTMLButtonElement = document.createElement('button'); + button.classList.add('fieldColourEyedropper'); + button.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.setValue(this.valueWhenEditorWasOpened); + } + }); + container.appendChild(document.createElement('hr')); + container.appendChild(button); + this.hsvBoundEvents.push( + Blockly.browserEvents.conditionalBind( + button, + 'click', + this, + this.onEyedropperEvent, + ), + ); + } + + this.dropdownContainer = container; + + this.updateSliderValues(); + } + + /** + * Updates the ARIA roles and label for this field. + */ + override recomputeAriaContext(): boolean { + const shouldCustomize = super.recomputeAriaContext(); + if (!shouldCustomize) return false; + Blockly.utils.aria.setState( + this.getFocusableElement(), + Blockly.utils.aria.State.HASPOPUP, + 'menu', + ); + return true; + } + + /** Disposes of events and DOM-references belonging to the colour editor. */ + private dropdownDisposeSliders(): void { + for (const event of this.hsvBoundEvents) { + Blockly.browserEvents.unbind(event); + } + this.hsvBoundEvents.length = 0; + this.hueReadout = null; + this.hueSlider = null; + this.saturationReadout = null; + this.saturationSlider = null; + this.brightnessReadout = null; + this.brightnessSlider = null; + this.dropdownContainer = null; + + if ( + this.sourceBlock_ && + Blockly.Events.isEnabled() && + this.valueWhenEditorWasOpened !== null && + this.valueWhenEditorWasOpened !== this.value_ + ) { + // When closing a field input widget, fire an event indicating that the + // user has completed a sequence of changes. The value may have changed + // multiple times while the editor was open, but this will fire an event + // containing the value when the editor was opened as well as the new one. + Blockly.Events.fire( + new (Blockly.Events.get(Blockly.Events.BLOCK_CHANGE))( + this.sourceBlock_, + 'field', + this.name || null, + this.valueWhenEditorWasOpened, + this.value_, + ), + ); + this.valueWhenEditorWasOpened = null; + } + } + + /** + * A helper function that converts a colour, specified by the provided hue, + * saturation, and brightness parameters, into a hexadecimal string in the + * format "#rrggbb". + * + * @param hue The hue of the colour. + * @param saturation The saturation of the colour. + * @param brightness The brightness of the colour. + * @returns A hexadecimal representation of the colour in the format "#rrggbb" + */ + private static hsvToHex( + hue: number, + saturation: number, + brightness: number, + ): string { + FieldColourHsvSliders.helperHsv.h = hue; + FieldColourHsvSliders.helperHsv.s = saturation; + FieldColourHsvSliders.helperHsv.v = brightness; + return FieldColourHsvSliders.helperRgb + .loadFromHsv(FieldColourHsvSliders.helperHsv) + .toHex(); + } + + /** + * Updates the value of this field based on the editor sliders. + * + * @param event Unused. + */ + private onSliderChange(event?: Event): void { + if (!this.hueSlider || !this.saturationSlider || !this.brightnessSlider) { + throw new Error('The HSV sliders are missing.'); + } + const hue: number = + parseFloat(this.hueSlider.value) / FieldColourHsvSliders.HUE_SLIDER_MAX; + const saturation: number = + parseFloat(this.saturationSlider.value) / + FieldColourHsvSliders.SATURATION_SLIDER_MAX; + const brightness: number = + parseFloat(this.brightnessSlider.value) / + FieldColourHsvSliders.BRIGHTNESS_SLIDER_MAX; + this.setIntermediateValue( + FieldColourHsvSliders.hsvToHex(hue, saturation, brightness), + ); + this.renderSliders(); + } + + /** + * Updates the value of this field and editor sliders using an eyedropper. + * + * @param event Unused. + */ + private onEyedropperEvent(event?: Event): void { + if (window.EyeDropper) { + const eyeDropper: EyeDropper = new window.EyeDropper(); + eyeDropper.open().then( + (result) => { + this.setIntermediateValue(result.sRGBHex); + this.updateSliderValues(); + }, + // Prevent throwing by handling promise rejection and doing nothing. + () => {}, + ); + } + } + + private setIntermediateValue(value: string): void { + // Intermediate value changes from user input are not confirmed until the + // user closes the editor, and may be numerous. Inhibit reporting these as + // normal block change events, and instead report them as special + // intermediate changes that do not get recorded in undo history. + const oldValue = this.value_; + // Change the field's value without firing the normal change event. + this.setValue(value, false); + if ( + this.sourceBlock_ && + Blockly.Events.isEnabled() && + this.value_ !== oldValue + ) { + // Fire a special event indicating that the value changed but the change + // isn't complete yet and normal field change listeners can wait. + Blockly.Events.fire( + new (Blockly.Events.get( + Blockly.Events.BLOCK_FIELD_INTERMEDIATE_CHANGE, + ))(this.sourceBlock_, this.name || null, oldValue, this.value_), + ); + } + } + + /** + * Updates the gradient backgrounds of the slider tracks and readouts based + * on the slider values. + */ + private renderSliders(): void { + if ( + !this.hueSlider || + !this.hueReadout || + !this.saturationSlider || + !this.saturationReadout || + !this.brightnessSlider || + !this.brightnessReadout + ) { + throw new Error('The HSV sliders are missing.'); + } + this.hueReadout.textContent = this.hueSlider.value; + this.saturationReadout.textContent = this.saturationSlider.value; + this.brightnessReadout.textContent = this.brightnessSlider.value; + + const h: number = + parseFloat(this.hueSlider.value) / FieldColourHsvSliders.HUE_SLIDER_MAX; + const s: number = + parseFloat(this.saturationSlider.value) / + FieldColourHsvSliders.SATURATION_SLIDER_MAX; + const v: number = + parseFloat(this.brightnessSlider.value) / + FieldColourHsvSliders.BRIGHTNESS_SLIDER_MAX; + + // The hue slider needs intermediate gradient control points to include all + // colours of the rainbow. + let hueGradient = 'linear-gradient(to right, '; + hueGradient += + FieldColourHsvSliders.hsvToHex(0 / 6, s, v) + + ` ${FieldColourHsvSliders.THUMB_RADIUS}px, `; + hueGradient += FieldColourHsvSliders.hsvToHex(1 / 6, s, v) + ', '; + hueGradient += FieldColourHsvSliders.hsvToHex(2 / 6, s, v) + ', '; + hueGradient += FieldColourHsvSliders.hsvToHex(3 / 6, s, v) + ', '; + hueGradient += FieldColourHsvSliders.hsvToHex(4 / 6, s, v) + ', '; + hueGradient += FieldColourHsvSliders.hsvToHex(5 / 6, s, v) + ', '; + hueGradient += + FieldColourHsvSliders.hsvToHex(6 / 6, s, v) + + ` calc(100% - ${FieldColourHsvSliders.THUMB_RADIUS}px))`; + this.hueSlider.style.setProperty('--slider-track-background', hueGradient); + + // The saturation slider only needs gradient control points at each end. + let saturationGradient = 'linear-gradient(to right, '; + saturationGradient += + FieldColourHsvSliders.hsvToHex(h, 0, v) + + ` ${FieldColourHsvSliders.THUMB_RADIUS}px, `; + saturationGradient += + FieldColourHsvSliders.hsvToHex(h, 1, v) + + ` calc(100% - ${FieldColourHsvSliders.THUMB_RADIUS}px))`; + this.saturationSlider.style.setProperty( + '--slider-track-background', + saturationGradient, + ); + + // The brightness slider only needs gradient control points at each end. + let brightnessGradient = 'linear-gradient(to right, '; + brightnessGradient += + FieldColourHsvSliders.hsvToHex(h, s, 0) + + ` ${FieldColourHsvSliders.THUMB_RADIUS}px, `; + brightnessGradient += + FieldColourHsvSliders.hsvToHex(h, s, 1) + + ` calc(100% - ${FieldColourHsvSliders.THUMB_RADIUS}px))`; + this.brightnessSlider.style.setProperty( + '--slider-track-background', + brightnessGradient, + ); + } + + /** Updates slider values based on the current value of the field. */ + private updateSliderValues(): void { + if (!this.hueSlider || !this.saturationSlider || !this.brightnessSlider) { + return; + } + + const hsv: HsvColour = FieldColourHsvSliders.helperHsv.loadFromRgb( + FieldColourHsvSliders.helperRgb.loadFromHex(this.getValue() ?? ''), + ); + + this.hueSlider.value = String(hsv.h * FieldColourHsvSliders.HUE_SLIDER_MAX); + this.saturationSlider.value = String( + hsv.s * FieldColourHsvSliders.SATURATION_SLIDER_MAX, + ); + this.brightnessSlider.value = String( + hsv.v * FieldColourHsvSliders.BRIGHTNESS_SLIDER_MAX, + ); + + this.renderSliders(); + } + + override getAriaValue() { + const hsv: HsvColour = FieldColourHsvSliders.helperHsv.loadFromRgb( + FieldColourHsvSliders.helperRgb.loadFromHex(this.getValue() ?? ''), + ); + + const components = []; + const brightness = hsv.v * 100; + if (brightness === 0) { + return 'black'; + } else if (brightness <= 25) { + components.push('very dark'); + } else if (brightness < 75) { + components.push('dark'); + } + + const saturation = hsv.s * 100; + if (saturation === 0) { + if (brightness === 100) { + return 'white'; + } + components.push('grey'); + return components.join(' '); + } else if (saturation <= 30) { + components.push(brightness <= 60 ? 'greyish' : 'pale'); + } else if (saturation >= 75) { + components.push('vibrant'); + } + + const hue = hsv.h * 360; + if (hue >= 330 || hue <= 10) { + components.push('red'); + } else if (hue <= 45) { + components.push('orange'); + } else if (hue <= 65) { + components.push('yellow'); + } else if (hue <= 160) { + components.push('green'); + } else if (hue <= 190) { + components.push('cyan'); + } else if (hue <= 260) { + components.push('blue'); + } else if (hue <= 290) { + components.push('purple'); + } else { + components.push('fuchsia'); + } + + return components.join(' '); + } +} + +Blockly.fieldRegistry.register( + 'field_colour_hsv_sliders', + FieldColourHsvSliders, +); + +// CSS for colour slider fields. +Blockly.Css.register(` +.fieldColourSliderContainer { + padding: 4px; + font-family: Roboto, Arial, sans-serif; + color: #5c5c5c; +} +.fieldColourSliderContainer hr { + border: none; + border-top: 1px solid #bbb; +} +.fieldColourSliderLabel { + display: flex; + justify-content: space-between; +} +.fieldColourEyedropper { + appearance: none; + position: relative; + border: none; + border-radius: 4px; + background: transparent; + font: inherit; + color: inherit; + cursor: pointer; + width: 100%; + margin: 0; + display: flex; + justify-content: center; + align-items: center; +} +.fieldColourEyedropper:hover { + background: rgba(0,0,0,0.1) +} +.fieldColourEyedropper input { + opacity: 0; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} +.fieldColourEyedropper::before { + content: "Eyedropper"; +} +.fieldColourEyedropper::after { + content: ""; + margin-left: 8px; + width: 24px; + height: 24px; + background: currentColor; + pointer-events: none; + -webkit-mask-image: var(--customize-dial-symbol); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-image: var(--customize-dial-symbol); + mask-repeat: no-repeat; + mask-position: center; + --customize-dial-symbol: url('data:image/svg+xml,\ + \ + \ + \ + '); +} +.blocklyKeyboardNavigation .fieldColourEyedropper:focus { + outline: none; + border: var(--blockly-selection-width) solid var(--blockly-active-node-color); + border-radius: 4px; +} +.fieldColourSlider { + -webkit-appearance: none; + width: 150px; + height: 24px; + margin: 4px 8px 24px 8px; + padding: 0; +} +.fieldColourSlider:last-child { + margin-bottom: 4px; +} +.fieldColourSlider:focus { + outline: none; +} +/* Webkit */ +.fieldColourSlider::-webkit-slider-runnable-track { + background: var(--slider-track-background); + border-radius: 8px; + height: 16px; +} +.fieldColourSlider::-webkit-slider-thumb { + -webkit-appearance: none; + background: #fff; + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(0,0,0,.15); + cursor: pointer; + width: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px; + height: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px; + margin-top: -4px; +} +/* Firefox */ +.fieldColourSlider::-moz-range-track { + background: var(--slider-track-background); + border-radius: 8px; + height: 16px; +} +.fieldColourSlider::-moz-range-thumb { + background: #fff; + border: none; + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(0,0,0,.15); + cursor: pointer; + width: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px; + height: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px; +} +.fieldColourSlider::-moz-focus-outer { + /* override the focus border style */ + border: 0; +} +/* IE */ +.fieldColourSlider::-ms-track { + background: var(--slider-track-background); + border-radius: 12px; + width: 100%; + height: 24px; + /* remove default tick marks */ + color: transparent; +} +.fieldColourSlider::-ms-fill-lower { + background: transparent; +} +.fieldColourSlider::-ms-fill-upper { + background: transparent; +} +.fieldColourSlider::-ms-thumb { + background: #fff; + border: none; + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(0,0,0,.15); + cursor: pointer; + width: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px; + height: ${FieldColourHsvSliders.THUMB_RADIUS * 2}px; +} +.blocklyKeyboardNavigation .fieldColourSlider:focus { + border: var(--blockly-selection-width) solid var(--blockly-active-node-color); + border-radius: 16px; + padding: 1px; +} +`); diff --git a/packages/plugins/field-colour-hsv-sliders/src/index.ts b/packages/plugins/field-colour-hsv-sliders/src/index.ts new file mode 100644 index 00000000000..b5899eff30c --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/src/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './field_colour_hsv_sliders'; diff --git a/packages/plugins/field-colour-hsv-sliders/test/field_colour_hsv_sliders_test.mocha.js b/packages/plugins/field-colour-hsv-sliders/test/field_colour_hsv_sliders_test.mocha.js new file mode 100644 index 00000000000..352aa274476 --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/test/field_colour_hsv_sliders_test.mocha.js @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const { + assertColourHsvSlidersField, + assertColourHsvSlidersFieldDefault, +} = require('./field_colour_hsv_sliders_test_helpers.mocha'); +const {testHelpers} = require('@blockly/dev-tools'); +const {FieldColourHsvSliders} = require('../src/index'); + +const { + FieldCreationTestCase, + FieldValueTestCase, + runConstructorSuiteTests, + runFromJsonSuiteTests, + runSetValueTests, +} = testHelpers; + +suite('FieldColourHsvSliders', function () { + setup(function () { + this.jsdomCleanup = require('jsdom-global')( + '
', + ); + this.field = new FieldColourHsvSliders(); + }); + + teardown(function () { + this.jsdomCleanup(); + }); + + /** + * Configuration for field tests with invalid values. + * @type {Array} + */ + const invalidValueTestCases = [ + {title: 'Undefined', value: undefined}, + {title: 'Null', value: null}, + {title: 'NaN', value: NaN}, + {title: 'Non-Parsable String', value: 'wrong'}, + {title: 'Integer', value: 1}, + {title: 'Float', value: 1.5}, + {title: 'Integer String', value: '1'}, + {title: 'Float String', value: '1.5'}, + {title: 'Infinity', value: Infinity}, + {title: 'Negative Infinity', value: -Infinity}, + {title: 'Infinity String', value: 'Infinity'}, + {title: 'Negative Infinity String', value: '-Infinity'}, + ]; + /** + * Configuration for field tests with valid values. + * @type {Array} + */ + const validValueTestCases = [ + { + title: 'Red', + value: '#ff0000', + expectedValue: '#ff0000', + expectedText: '#f00', + }, + { + title: 'Black', + value: '#000000', + expectedValue: '#000000', + expectedText: '#000', + }, + { + title: 'White', + value: '#ffffff', + expectedValue: '#ffffff', + expectedText: '#fff', + }, + { + title: 'Gray', + value: '#7f7f7f', + expectedValue: '#7f7f7f', + expectedText: '#7f7f7f', + }, + { + title: 'Shortened String', + value: 'bad', + expectedValue: '#bbaadd', + expectedText: '#bad', + }, + ]; + const addArgsAndJson = function (testCase) { + testCase.args = [testCase.value]; + testCase.json = {colour: testCase.value}; + }; + invalidValueTestCases.forEach(addArgsAndJson); + validValueTestCases.forEach(addArgsAndJson); + + /** + * Asserts that the field properties are correct based on the test case. + * @param {FieldColourHsvSliders} field The field to check. + * @param {FieldValueTestCase} testCase The test case. + */ + const validTestCaseAssertField = function (field, testCase) { + assertColourHsvSlidersField( + field, + testCase.expectedValue, + testCase.expectedText, + ); + }; + + runConstructorSuiteTests( + FieldColourHsvSliders, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertColourHsvSlidersFieldDefault, + ); + + runFromJsonSuiteTests( + FieldColourHsvSliders, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertColourHsvSlidersFieldDefault, + ); + + suite('setValue', function () { + suite('Empty -> New Value', function () { + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + '#ffffff', + '#fff', + ); + }); + suite('Value -> New Value', function () { + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + '#ffffff', + '#fff', + ); + }); + }); +}); diff --git a/packages/plugins/field-colour-hsv-sliders/test/field_colour_hsv_sliders_test_helpers.mocha.js b/packages/plugins/field-colour-hsv-sliders/test/field_colour_hsv_sliders_test_helpers.mocha.js new file mode 100644 index 00000000000..74e5f80df15 --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/test/field_colour_hsv_sliders_test_helpers.mocha.js @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const {assertFieldValue} = require('@blockly/dev-tools').testHelpers; +const {FieldColourHsvSliders} = require('../src/index'); + +/** + * Assert the colour hsv sliders field options. + * @param {FieldColourHsvSliders} colourHsvSlidersField The colour hsv sliders + * field. + * @param {string} expectedValue The expected value. + * @param {string} expectedText The expected text. + */ +function assertColourHsvSlidersField( + colourHsvSlidersField, + expectedValue, + expectedText, +) { + assertFieldValue(colourHsvSlidersField, expectedValue, expectedText); +} + +/** + * Assert the slider field's value is the default value. + * @param {FieldColourHsvSliders} colourHsvSlidersField The slider field. + */ +function assertColourHsvSlidersFieldDefault(colourHsvSlidersField) { + assertColourHsvSlidersField(colourHsvSlidersField, '#ffffff', '#fff'); +} + +module.exports = { + assertColourHsvSlidersField, + assertColourHsvSlidersFieldDefault, +}; diff --git a/packages/plugins/field-colour-hsv-sliders/test/index.html b/packages/plugins/field-colour-hsv-sliders/test/index.html new file mode 100644 index 00000000000..e80b2952ae2 --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/test/index.html @@ -0,0 +1,18 @@ + + + + + Blockly Field Colour HSV Sliders Playground + + + + +
+ + + diff --git a/packages/plugins/field-colour-hsv-sliders/test/index.ts b/packages/plugins/field-colour-hsv-sliders/test/index.ts new file mode 100644 index 00000000000..0be3ab2d7bc --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/test/index.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Colour HSV Sliders field test playground. + */ + +import * as Blockly from 'blockly'; +import {createPlayground} from '@blockly/dev-tools'; +import '../src/index'; + +/** + * An array of blocks that are defined only for the purposes of + * manually and visually testing the HSV slider field. + */ +const testBlockDefinitions = [ + { + type: 'test_standard_field_values', + message0: '%1', + args0: [ + { + type: 'field_colour_hsv_sliders', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_colour_hsv_sliders`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values', + message0: '%1', + args0: [ + { + type: 'field_colour_hsv_sliders', + name: 'FIELDNAME', + colour: '#ff0000', + alt: { + type: 'field_label', + text: `No field_colour_hsv_sliders`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_standard_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_colour_hsv_sliders', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_colour_hsv_sliders`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_colour_hsv_sliders', + name: 'FIELDNAME', + colour: '#ff0000', + alt: { + type: 'field_label', + text: `No field_colour_hsv_sliders`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_parent_block', + message0: 'parent %1', + args0: [ + { + type: 'input_value', + name: 'INPUT', + }, + ], + previousStatement: null, + nextStatement: null, + style: 'loop_blocks', + }, +]; + +Blockly.defineBlocksWithJsonArray(testBlockDefinitions); + +/** + * A test toolbox containing the exported blocks and a variety of + * test blocks to exercise the colour field in different contexts + * (on a shadow block, as the only field on a block, etc). + * These are in a simple toolbox, rather than a category toolbox, so that + * they are all instantiated every time the test page is opened. + */ +const jsonToolbox = { + contents: [ + { + kind: 'label', + text: 'Test blocks: default field values', + }, + { + kind: 'block', + type: 'test_standard_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_standard_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values_and_label', + }, + }, + }, + }, + { + kind: 'label', + text: 'Test blocks: custom field values', + }, + { + kind: 'block', + type: 'test_custom_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_custom_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values_and_label', + }, + }, + }, + }, + ], +}; + +/** + * Create a workspace. + * + * @param blocklyDiv The blockly container div. + * @param options The Blockly options. + * @returns The created workspace. + */ +function createWorkspace( + blocklyDiv: HTMLElement, + options: Blockly.BlocklyOptions, +): Blockly.WorkspaceSvg { + const workspace = Blockly.inject(blocklyDiv, options); + return workspace; +} + +document.addEventListener('DOMContentLoaded', function () { + const defaultOptions: Blockly.BlocklyOptions = { + toolbox: jsonToolbox, + }; + const rootElement = document.getElementById('root'); + if (rootElement) { + createPlayground(rootElement, createWorkspace, defaultOptions); + } +}); diff --git a/packages/plugins/field-colour-hsv-sliders/tsconfig.json b/packages/plugins/field-colour-hsv-sliders/tsconfig.json new file mode 100644 index 00000000000..99858c525f1 --- /dev/null +++ b/packages/plugins/field-colour-hsv-sliders/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "es2015", + "moduleResolution": "bundler", + "target": "es6", + "strict": true + }, + // NOTE: `test/**/*` is automatically included in `blockly-scripts start`. + // Only src matters for production builds. + "include": ["src"] +} diff --git a/packages/plugins/field-colour/CHANGELOG.md b/packages/plugins/field-colour/CHANGELOG.md new file mode 100644 index 00000000000..6342a4a510a --- /dev/null +++ b/packages/plugins/field-colour/CHANGELOG.md @@ -0,0 +1,545 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [13.1.0](https://github.com/RaspberryPiFoundation/blockly-samples/compare/v13.0.0...v13.1.0) (2026-06-30) + +### ⚠ BREAKING CHANGES + +* Bump Blockly dependency to v13 (#2704) + +### Bug Fixes + +* Improve accessibility of grid and colour fields ([#2724](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2724)) + +### Miscellaneous Chores + +* Bump Blockly dependency to v13 ([#2704](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2704)) + + +## [6.0.12](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-colour@6.0.11...@blockly/field-colour@6.0.12) (2026-04-09) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.11](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-colour@6.0.10...@blockly/field-colour@6.0.11) (2026-01-15) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.10](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-colour@6.0.9...@blockly/field-colour@6.0.10) (2026-01-05) + + +### Bug Fixes + +* update plugin URLs to RPF ([#2665](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2665)) ([6c34039](https://github.com/RaspberryPiFoundation/blockly-samples/commit/6c3403960b8070eb8e576bd5702bdb53a09ac475)) + + + +## [6.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.8...@blockly/field-colour@6.0.9) (2026-01-05) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.7...@blockly/field-colour@6.0.8) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.6...@blockly/field-colour@6.0.7) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.5...@blockly/field-colour@6.0.6) (2025-10-30) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.4...@blockly/field-colour@6.0.5) (2025-10-02) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.3...@blockly/field-colour@6.0.4) (2025-07-17) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.2...@blockly/field-colour@6.0.3) (2025-07-03) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.1...@blockly/field-colour@6.0.2) (2025-06-19) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour@6.0.0...@blockly/field-colour@6.0.1) (2025-05-22) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [6.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.19...@blockly/field-colour@6.0.0) (2025-05-16) + + +### ⚠ BREAKING CHANGES + +* update all plugins to blockly v12 (#2538) + +### Features + +* update all plugins to blockly v12 ([#2538](https://github.com/google/blockly-samples/issues/2538)) ([420ba31](https://github.com/google/blockly-samples/commit/420ba3124b260b2c06e8e1d61be44e870780d95f)) + + + +## [5.0.19](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.18...@blockly/field-colour@5.0.19) (2025-05-16) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.18](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.17...@blockly/field-colour@5.0.18) (2025-05-15) + + +### Reverts + +* Revert "fix: Add `getClass()` to `FieldColour`. (#2506)" ([144b6d0](https://github.com/google/blockly-samples/commit/144b6d0c4b939e1f329986220e30ebdfcf93453c)), closes [#2506](https://github.com/google/blockly-samples/issues/2506) + + + + + +## [5.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.16...@blockly/field-colour@5.0.17) (2025-05-08) + + +### Bug Fixes + +* Add `getClass()` to `FieldColour`. ([#2506](https://github.com/google/blockly-samples/issues/2506)) ([f5a577b](https://github.com/google/blockly-samples/commit/f5a577b8fe25b6fbf1502ca85902c22777fd8270)) + + + + + +## [5.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.15...@blockly/field-colour@5.0.16) (2025-03-27) + + +### Bug Fixes + +* Don't call blur() when mouse leaves the colour picker. ([#2499](https://github.com/google/blockly-samples/issues/2499)) ([e8d4a87](https://github.com/google/blockly-samples/commit/e8d4a874c44cdb0e1ad05b08122251b9e758655c)) + + + + + +## [5.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.14...@blockly/field-colour@5.0.15) (2025-02-13) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.13...@blockly/field-colour@5.0.14) (2025-01-23) + + +### Bug Fixes + +* Fix bug that prevented keyboard navigation of color swatches. ([#2479](https://github.com/google/blockly-samples/issues/2479)) ([5945e7c](https://github.com/google/blockly-samples/commit/5945e7c0412faedc08c422f9f538ef22193cc659)) + + + + + +## [5.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.12...@blockly/field-colour@5.0.13) (2024-12-19) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.11...@blockly/field-colour@5.0.12) (2024-12-03) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.10...@blockly/field-colour@5.0.11) (2024-11-07) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.9...@blockly/field-colour@5.0.10) (2024-09-26) + + +### Bug Fixes + +* **field-*:** Remove unneeded `fieldRegistry.unregister` calls ([#2454](https://github.com/google/blockly-samples/issues/2454)) ([b3ba30e](https://github.com/google/blockly-samples/commit/b3ba30e23dddf0bd98c266659aa229ba6ba685b0)), closes [#2453](https://github.com/google/blockly-samples/issues/2453) + + + + + +## [5.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.8...@blockly/field-colour@5.0.9) (2024-08-29) + + +### Bug Fixes + +* add documentation about locales for colour blocks ([#2452](https://github.com/google/blockly-samples/issues/2452)) ([e06f13d](https://github.com/google/blockly-samples/commit/e06f13de06cb1bea9358ad46306051f4eb1bd05a)) +* predeploy plugins ([#2449](https://github.com/google/blockly-samples/issues/2449)) ([6b36d8b](https://github.com/google/blockly-samples/commit/6b36d8b344a969f79d89bbc7dcee29ae554759f9)) + + + + + +## [5.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.7...@blockly/field-colour@5.0.8) (2024-08-22) + + +### Bug Fixes + +* lerna v8 breaking our whole shebangle ([#2446](https://github.com/google/blockly-samples/issues/2446)) ([b8b4c21](https://github.com/google/blockly-samples/commit/b8b4c21d4eaf81f527336ae46f6679ff99ac23c3)) + + + + + +## [5.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.6...@blockly/field-colour@5.0.7) (2024-08-08) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.5...@blockly/field-colour@5.0.6) (2024-08-01) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.4...@blockly/field-colour@5.0.5) (2024-07-25) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.3...@blockly/field-colour@5.0.4) (2024-07-11) + + +### Reverts + +* Revert "chore(deps): Bump the npm_and_yarn group across 54 directories with 19 updates (#2403)" (#2410) ([bad8ffb](https://github.com/google/blockly-samples/commit/bad8ffbf85caa4e5b68d2f010cd0deaa9e3dd98f)), closes [#2403](https://github.com/google/blockly-samples/issues/2403) [#2410](https://github.com/google/blockly-samples/issues/2410) + + + +## [5.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.2...@blockly/field-colour@5.0.3) (2024-07-04) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.1...@blockly/field-colour@5.0.2) (2024-06-06) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [5.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour@5.0.0...@blockly/field-colour@5.0.1) (2024-05-30) + + +### Bug Fixes + +* multiple blockly instances ([#2375](https://github.com/google/blockly-samples/issues/2375)) ([b231e59](https://github.com/google/blockly-samples/commit/b231e598f2f5f5b0abbfd01d981e35572ad50a26)) + + + +## [5.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour@4.0.4...@blockly/field-colour@5.0.0) (2024-05-21) + + +### ⚠ BREAKING CHANGES + +* update all plugins to v11 of blockly + +### Features + +* update all plugins to v11 of blockly ([5fcd3e7](https://github.com/google/blockly-samples/commit/5fcd3e7d53eaadffe9bda9a378b404d34b2f8be2)) +* upgrade ts version, fix field validators ([b2d2191](https://github.com/google/blockly-samples/commit/b2d2191ff11b30347b33f95acf6e58f7ce54d004)) + + +### Bug Fixes + +* Call blocks handle both manual disabling and disabled defs ([#2334](https://github.com/google/blockly-samples/issues/2334)) ([5eade55](https://github.com/google/blockly-samples/commit/5eade55779c4022d14ad4472ff32c93c78199887)) + + + +## [4.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour@4.0.3...@blockly/field-colour@4.0.4) (2024-05-16) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [4.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour@4.0.2...@blockly/field-colour@4.0.3) (2024-05-09) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [4.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour@4.0.1...@blockly/field-colour@4.0.2) (2024-04-25) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [4.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour@4.0.0...@blockly/field-colour@4.0.1) (2024-04-11) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [4.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.13...@blockly/field-colour@4.0.0) (2024-04-04) + + +### ⚠ BREAKING CHANGES + +* The multiline text input field no longer registers itself on load. The developer must either manually register the field or install blocks, which will install the field. This is part of a move to have no side effects in field and block definitions, so that tree-shaking can remove unwanted fields and blocks. +* The angle field no longer registers itself on load. The developer must manually register the field. This is part of a move to have no side effects in field and block definitions, so that tree-shaking can remove unwanted fields and blocks. + +### Features + +* add block definitions to fields ([#2299](https://github.com/google/blockly-samples/issues/2299)) ([c197af6](https://github.com/google/blockly-samples/commit/c197af6c67bc989bbdb4e83a62abc08167bcbbfe)), closes [#2162](https://github.com/google/blockly-samples/issues/2162) [#2202](https://github.com/google/blockly-samples/issues/2202) [#2221](https://github.com/google/blockly-samples/issues/2221) [#2211](https://github.com/google/blockly-samples/issues/2211) [#2220](https://github.com/google/blockly-samples/issues/2220) [#2232](https://github.com/google/blockly-samples/issues/2232) [#2233](https://github.com/google/blockly-samples/issues/2233) [#2296](https://github.com/google/blockly-samples/issues/2296) + + + +## [3.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.12...@blockly/field-colour@3.0.13) (2024-03-28) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.11...@blockly/field-colour@3.0.12) (2024-02-08) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.10...@blockly/field-colour@3.0.11) (2023-12-14) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.9...@blockly/field-colour@3.0.10) (2023-12-07) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.8...@blockly/field-colour@3.0.9) (2023-11-09) + + +### Bug Fixes + +* zelos full block colour field rendering incorrectly ([#1932](https://github.com/google/blockly-samples/issues/1932)) ([49fe34f](https://github.com/google/blockly-samples/commit/49fe34fa887af1c6295fbcd294839d20fb86b0c8)) + + + +## [3.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.7...@blockly/field-colour@3.0.8) (2023-11-02) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.6...@blockly/field-colour@3.0.7) (2023-10-30) + + +### Bug Fixes + +* Field Colour Plugin types ([#1964](https://github.com/google/blockly-samples/issues/1964)) ([8aee09e](https://github.com/google/blockly-samples/commit/8aee09e9375cbcff65cc7b39530daaa3bdf84182)) +* **packaging:** Don't advertise non-existent ESM entrypoints ([#2022](https://github.com/google/blockly-samples/issues/2022)) ([d019198](https://github.com/google/blockly-samples/commit/d0191984399b784e2928b8fb4c58257bfa857655)), closes [#1877](https://github.com/google/blockly-samples/issues/1877) +* update tsconfigs to publish types accurately ([df4b084](https://github.com/google/blockly-samples/commit/df4b0844af712f5025a2ec842458b828f3147676)) +* use includes rather than excludes ([40efc25](https://github.com/google/blockly-samples/commit/40efc255329e3ca476ccc247b95a2d05dd77b45e)) + + + +## [3.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.5...@blockly/field-colour@3.0.6) (2023-09-21) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.4...@blockly/field-colour@3.0.5) (2023-09-14) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.3...@blockly/field-colour@3.0.4) (2023-08-31) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.2...@blockly/field-colour@3.0.3) (2023-08-17) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.1...@blockly/field-colour@3.0.2) (2023-07-24) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour@3.0.0...@blockly/field-colour@3.0.1) (2023-07-20) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [3.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour@2.0.1...@blockly/field-colour@3.0.0) (2023-07-13) + + +### ⚠ BREAKING CHANGES + +* updated `plugins/field-date` to TypeScript (#1705) + +### Features + +* updated `plugins/field-date` to TypeScript ([#1705](https://github.com/google/blockly-samples/issues/1705)) ([e5531ff](https://github.com/google/blockly-samples/commit/e5531fffe188ee361a16fe48ed126b34e51a8d30)) + + + +## [2.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour@2.0.0...@blockly/field-colour@2.0.1) (2023-07-06) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [2.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour@1.2.1...@blockly/field-colour@2.0.0) (2023-06-28) + + +### ⚠ BREAKING CHANGES + +* update all plugins to use blockly-v10.0.0 (#1744) + +### Features + +* update all plugins to use blockly-v10.0.0 ([#1744](https://github.com/google/blockly-samples/issues/1744)) ([6cc88cb](https://github.com/google/blockly-samples/commit/6cc88cbef39d4ad664a668d3d46eb29ba7292f9c)) + + + +## [1.2.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour@1.2.0...@blockly/field-colour@1.2.1) (2023-06-22) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## [1.2.0](https://github.com/google/blockly-samples/compare/@blockly/field-colour@1.1.1...@blockly/field-colour@1.2.0) (2023-05-25) + + +### Features + +* Revise fields ([#1700](https://github.com/google/blockly-samples/issues/1700)) ([c326f7b](https://github.com/google/blockly-samples/commit/c326f7b1ef54fe45387046c693ebd5afe2b2c571)) + + + +## [1.1.1](https://github.com/google/blockly-samples/compare/@blockly/field-colour@1.1.0...@blockly/field-colour@1.1.1) (2023-05-18) + +**Note:** Version bump only for package @blockly/field-colour + + + + + +## 1.1.0 (2023-05-11) + + +### Features + +* Add angle, colour, and multiline fields. (Rebase of [#1674](https://github.com/google/blockly-samples/issues/1674)) ([#1693](https://github.com/google/blockly-samples/issues/1693)) ([1b447f4](https://github.com/google/blockly-samples/commit/1b447f41d6293f14e846b8a4d82d289a5637a99a)) diff --git a/packages/plugins/field-colour/README.md b/packages/plugins/field-colour/README.md new file mode 100644 index 00000000000..1fb1f42fbbc --- /dev/null +++ b/packages/plugins/field-colour/README.md @@ -0,0 +1,308 @@ +# @blockly/field-colour [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +A [Blockly](https://www.npmjs.com/package/blockly) field and blocks for +choosing and combining colours. + +## Installation + +### Yarn + +``` +yarn add @blockly/field-colour +``` + +### npm + +``` +npm install @blockly/field-colour --save +``` + +## Usage + +If you want to use this field in a block definition, you must install it by +calling `registerFieldColour` before instantiating your blocks. If another +field is registered under the same name (`field_colour`), this field will +overwrite it. + +If you [install the blocks](#blocks) in this package, the field will +automatically be installed. + +### Field + +The colour field stores a string as its `value`, and a string as its `text`. Its +`value` is a string with the format `#rrggbb`, while its `text` may be a string +with the format `#rgb` if possible. + +#### Colour field + +![](https://github.com/RaspberryPiFoundation/blockly-samples/raw/master/plugins/field-colour/readme-media/on_block.png) + +#### Colour field with editor open + +![](https://github.com/RaspberryPiFoundation/blockly-samples/raw/master/plugins/field-colour/readme-media/with_editor.png) + +#### Colour field on collapsed block + +![](https://github.com/RaspberryPiFoundation/blockly-samples/raw/master/plugins/field-colour/readme-media/collapsed.png) + +### Creation + +#### JavaScript + +```js +import * as Blockly from 'blockly'; +import {registerFieldColour} from '@blockly/field-colour'; + +registerFieldColour(); +Blockly.Blocks['test_field_colour'] = { + init: function () { + this.appendDummyInput() + .appendField('colour: ') + .appendField( + new FieldColour('#ff4040', null, { + colourOptions: [ + '#ff4040', + '#ff8080', + '#ffc0c0', + '#4040ff', + '#8080ff', + '#c0c0ff', + ], + colourTitles: [ + 'dark pink', + 'pink', + 'light pink', + 'dark blue', + 'blue', + 'light blue', + ], + columns: 3, + }), + 'FIELDNAME', + ); + }, +}; +``` + +#### JSON + +```js +import * as Blockly from 'blockly'; +import {registerFieldColour} from '@blockly/field-colour'; + +registerFieldColour(); +Blockly.defineBlocksWithJsonArray([ + { + type: 'test_field_colour', + message0: 'colour: %1', + args0: [ + { + type: 'field_colour', + name: 'FIELDNAME', + colour: '#ff4040', + colourOptions: [ + '#ff4040', + '#ff8080', + '#ffc0c0', + '#4040ff', + '#8080ff', + '#c0c0ff', + ], + colourTitles: [ + 'dark pink', + 'pink', + 'light pink', + 'dark blue', + 'blue', + 'light blue', + ], + columns: 3, + }, + ], + }, +]); +``` + +The colour constructor takes in the following: + +- an optional `value` +- an optional [validator](#creating-a-colour-validator) +- an optional map of options, including: + - `colourOptions` + - `colourTitles` + - `columns` + +The `value` should be a string in the format `#rrggbb`. If no `value` +is given or the given `value` is invalid, the first entry in the +default colours array will be used. + +The following options can also be set in JSON: + +- `colourOptions` +- `colourTitles` +- `columns` + +Or they can be set using [JavaScript hooks](#editor-options). + +## Customization + +### Editor options + +The `setColours` +function can be used to set the colour options of a colour field. It takes in an +array of colour strings, which must be defined in `#rrggbb` format, and an +optional array of tooltips. If the tooltip array is not provided, the default +tooltip array will be used. + +Tooltips and colours are matched based on array index, not based on value. If +the colours array is longer than the tooltip array, the tooltips for the extra +colours will be their `#rrggbb` value. + +The setColumns function sets the number of columns in the colour picker. + +#### JSON + +```js +{ + "type": "example_colour", + "message0": "colour: %1", + "args0": [ + { + "type": "field_colour", + "name": "COLOUR", + "colour": "#ff4040" + } + ], + "extensions": ["set_colours_extension"] +} +``` + +```js +Blockly.Extensions.register('set_colours_extension', function () { + var field = this.getField('COLOUR'); + field.setColours( + ['#ff4040', '#ff8080', '#ffc0c0', '#4040ff', '#8080ff', '#c0c0ff'], + ['dark pink', 'pink', 'light pink', 'dark blue', 'blue', 'light blue'], + ); + field.setColumns(3); +}); +``` + +This is done using a JSON +[extension](https://developers.google.com/blockly/guides/create-custom-blocks/define/extensions). + +#### JavaScript + +```js +Blockly.Blocks['example_colour'] = { + init: function () { + var field = new Blockly.FieldColour('#ff4040'); + field.setColours( + ['#ff4040', '#ff8080', '#ffc0c0', '#4040ff', '#8080ff', '#c0c0ff'], + ['dark pink', 'pink', 'light pink', 'dark blue', 'blue', 'light blue'], + ); + field.setColumns(3); + this.appendDummyInput().appendField('colour:').appendField(field, 'COLOUR'); + }, +}; +``` + +![Customized colour field editor](https://github.com/RaspberryPiFoundation/blockly-samples/raw/master/plugins/field-colour/readme-media/customized.png) + +#### Creating a colour validator + +Note: For information on validators in general see [Validators](https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators). + +A colour field's value is a `#rrggbb` format string, so any validators must +accept a `#rrggbb` format string, and return a `#rrggbb` format string, `null`, +or `undefined`. + +Here is an example of a validator that changes the colour of the block to match +the colour of the field. + +``` +function(newValue) { + this.getSourceBlock().setColour(newValue); + return newValue; +} +``` + +#### Block changing colour based on its colour field + +![](https://github.com/RaspberryPiFoundation/blockly-samples/raw/master/plugins/field-colour/readme-media/validator.gif) + +### Blocks + +This package also provides four blocks related to the colour field. Each block +has generators in JavaScript, Python, PHP, Lua, and Dart. + +- "colour_blend" takes in two colours and a ratio and outputs a single colour. +- "colour_picker" is a simple block with just the colour field and an output. +- "colour_random" generates a random colour. +- "colour_rgb" generates a colour based on red, green, and blue values. + +You can install all four blocks by calling `installAllBlocks`. This will +install the blocks and all of their dependencies, including the colour field. +When calling `installAllBlocks`—or any of the individual `installSomeBlock` +functions—you can supply one or more `CodeGenerator` instances (e.g. +`javascriptGenerator`), and the install function will also install the correct +generator function for each block for the corresponding language(s). + +```js +import {javascriptGenerator} from 'blockly/javascript'; +import {dartGenerator} from 'blockly/dart'; +import {phpGenerator} from 'blockly/php'; +import {pythonGenerator} from 'blockly/python'; +import {luaGenerator} from 'blockly/lua'; +import {installAllBlocks as installColourBlocks} from '@blockly/field-colour'; + +// Installs all four blocks, the colour field, and all language generators. +installColourBlocks({ + javascript: javascriptGenerator, + dart: dartGenerator, + lua: luaGenerator, + python: pythonGenerator, + php: phpGenerator, +}); +``` + +If you only want to install a single block, you can call that block's +`installBlock` function. The `generators` parameter is the same. + +```js +import {javascriptGenerator} from 'blockly/javascript'; +import {colourBlend} from '@blockly/field-colour'; + +// Installs the colour_blend block, the colour field, +// and the generator for colour_blend in JavaScript. +colourBlend.installBlock({ + javascript: javascriptGenerator, +}); +``` + +#### Message files and locales + +The blocks in this package contain text that can be localized into multiple +languages. As of August 2024, the relevant messages are included in the core +Blockly language files. For information on Blockly's approach to localization, +see [Localization](https://developers.google.com/blockly/guides/configure/web/translations) +in the developer's guide. + +If your blocks show `%{BKY_COLOUR_BLEND_TITLE}` or similar text instead +of the expected text, make sure that you either: + +- Import the default Blockly modules, which includes the English langfiles, or +- Explicitly import a language and call `setLocale` before using these blocks. + +For more information, see [Load a Blockly localization +table](https://developers.google.com/blockly/guides/configure/web/translations#load-blockly). + +### API Reference + +- `setColours`: Sets the colour options, and optionally the titles for the + options. The colours should be an array of `#rrggbb` strings. +- `setColumns`: Sets the number of columns the dropdown should have. + +## License + +Apache 2.0 diff --git a/packages/plugins/field-colour/package.json b/packages/plugins/field-colour/package.json new file mode 100644 index 00000000000..01b89f9b613 --- /dev/null +++ b/packages/plugins/field-colour/package.json @@ -0,0 +1,61 @@ +{ + "name": "@blockly/field-colour", + "version": "13.1.0", + "description": "A Blockly colour field.", + "scripts": { + "audit:fix": "blockly-scripts auditFix", + "build": "blockly-scripts build", + "clean": "blockly-scripts clean", + "lint": "eslint .", + "predeploy": "blockly-scripts predeploy", + "prepublishOnly": "npm run clean && npm run build", + "start": "blockly-scripts start", + "test": "blockly-scripts test" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.js", + "author": "Blockly Team", + "keywords": [ + "blockly", + "field", + "colour" + ], + "homepage": "packages/plugins/field-colour/package.jsonfield-colour#readme", + "bugs": { + "url": "https://github.com/RaspberryPiFoundation/blockly/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaspberryPiFoundation/blockly.git", + "directory": "packages/plugins/field-colour" + }, + "license": "Apache-2.0", + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src" + ], + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "@typescript-eslint/parser": "^8.61.1", + "chai": "^6.2.2", + "jsdom": "^29.1.1", + "jsdom-global": "^3.0.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@blockly/field-grid-dropdown": "^13.1.0" + } +} diff --git a/packages/plugins/field-colour/readme-media/collapsed.png b/packages/plugins/field-colour/readme-media/collapsed.png new file mode 100644 index 00000000000..3787dc73aec Binary files /dev/null and b/packages/plugins/field-colour/readme-media/collapsed.png differ diff --git a/packages/plugins/field-colour/readme-media/customized.png b/packages/plugins/field-colour/readme-media/customized.png new file mode 100644 index 00000000000..161860b9d18 Binary files /dev/null and b/packages/plugins/field-colour/readme-media/customized.png differ diff --git a/packages/plugins/field-colour/readme-media/on_block.png b/packages/plugins/field-colour/readme-media/on_block.png new file mode 100644 index 00000000000..ee3a66d96e6 Binary files /dev/null and b/packages/plugins/field-colour/readme-media/on_block.png differ diff --git a/packages/plugins/field-colour/readme-media/validator.gif b/packages/plugins/field-colour/readme-media/validator.gif new file mode 100644 index 00000000000..bfcd87d72b5 Binary files /dev/null and b/packages/plugins/field-colour/readme-media/validator.gif differ diff --git a/packages/plugins/field-colour/readme-media/with_editor.png b/packages/plugins/field-colour/readme-media/with_editor.png new file mode 100644 index 00000000000..8a88f57ae1d Binary files /dev/null and b/packages/plugins/field-colour/readme-media/with_editor.png differ diff --git a/packages/plugins/field-colour/src/blocks/colourBlend.ts b/packages/plugins/field-colour/src/blocks/colourBlend.ts new file mode 100644 index 00000000000..78237c85a89 --- /dev/null +++ b/packages/plugins/field-colour/src/blocks/colourBlend.ts @@ -0,0 +1,294 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Block, common as BlocklyCommon} from 'blockly/core'; +import { + JavascriptGenerator, + Order as JavascriptOrder, +} from 'blockly/javascript'; +import {DartGenerator, Order as DartOrder} from 'blockly/dart'; +import {LuaGenerator, Order as LuaOrder} from 'blockly/lua'; +import {PhpGenerator, Order as PhpOrder} from 'blockly/php'; +import {PythonGenerator, Order as PythonOrder} from 'blockly/python'; +import {registerFieldColour} from '../field_colour'; +import {Generators} from './generatorsType'; + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'colour_blend'; + +// Block for blending two colours together. +const jsonDefinition = { + type: BLOCK_NAME, + message0: + '%{BKY_COLOUR_BLEND_TITLE} %{BKY_COLOUR_BLEND_COLOUR1} ' + + '%1 %{BKY_COLOUR_BLEND_COLOUR2} %2 %{BKY_COLOUR_BLEND_RATIO} %3', + args0: [ + { + type: 'input_value', + name: 'COLOUR1', + check: 'Colour', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'COLOUR2', + check: 'Colour', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'RATIO', + check: 'Number', + align: 'RIGHT', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_BLEND_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_BLEND_TOOLTIP}', +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript( + block: Block, + generator: JavascriptGenerator, +): [string, JavascriptOrder] { + // Blend two colours together. + const colour1 = + generator.valueToCode(block, 'COLOUR1', JavascriptOrder.NONE) || + "'#000000'"; + const colour2 = + generator.valueToCode(block, 'COLOUR2', JavascriptOrder.NONE) || + "'#000000'"; + const ratio = + generator.valueToCode(block, 'RATIO', JavascriptOrder.NONE) || 0.5; + const functionName = generator.provideFunction_( + 'colourBlend', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}(c1, c2, ratio) { + ratio = Math.max(Math.min(Number(ratio), 1), 0); + var r1 = parseInt(c1.substring(1, 3), 16); + var g1 = parseInt(c1.substring(3, 5), 16); + var b1 = parseInt(c1.substring(5, 7), 16); + var r2 = parseInt(c2.substring(1, 3), 16); + var g2 = parseInt(c2.substring(3, 5), 16); + var b2 = parseInt(c2.substring(5, 7), 16); + var r = Math.round(r1 * (1 - ratio) + r2 * ratio); + var g = Math.round(g1 * (1 - ratio) + g2 * ratio); + var b = Math.round(b1 * (1 - ratio) + b2 * ratio); + r = ('0' + (r || 0).toString(16)).slice(-2); + g = ('0' + (g || 0).toString(16)).slice(-2); + b = ('0' + (b || 0).toString(16)).slice(-2); + return '#' + r + g + b; +} +`, + ); + const code = `${functionName}(${colour1}, ${colour2}, ${ratio})`; + return [code, JavascriptOrder.FUNCTION_CALL]; +} + +/** + * Dart block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The DartGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toDart( + block: Block, + generator: DartGenerator, +): [string, DartOrder] { + // Blend two colours together. + const colour1 = + generator.valueToCode(block, 'COLOUR1', DartOrder.NONE) || "'#000000'"; + const colour2 = + generator.valueToCode(block, 'COLOUR2', DartOrder.NONE) || "'#000000'"; + const ratio = generator.valueToCode(block, 'RATIO', DartOrder.NONE) || 0.5; + + // TODO(#7600): find better approach than casting to any to override + // CodeGenerator declaring .definitions protected. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (generator as any).definitions_['import_dart_math'] = + "import 'dart:math' as Math;"; + const functionName = generator.provideFunction_( + 'colour_blend', + ` +String ${generator.FUNCTION_NAME_PLACEHOLDER_}(String c1, String c2, num ratio) { + ratio = Math.max(Math.min(ratio, 1), 0); + int r1 = int.parse('0x\${c1.substring(1, 3)}'); + int g1 = int.parse('0x\${c1.substring(3, 5)}'); + int b1 = int.parse('0x\${c1.substring(5, 7)}'); + int r2 = int.parse('0x\${c2.substring(1, 3)}'); + int g2 = int.parse('0x\${c2.substring(3, 5)}'); + int b2 = int.parse('0x\${c2.substring(5, 7)}'); + num rn = (r1 * (1 - ratio) + r2 * ratio).round(); + String rs = rn.toInt().toRadixString(16); + num gn = (g1 * (1 - ratio) + g2 * ratio).round(); + String gs = gn.toInt().toRadixString(16); + num bn = (b1 * (1 - ratio) + b2 * ratio).round(); + String bs = bn.toInt().toRadixString(16); + rs = '0$rs'; + rs = rs.substring(rs.length - 2); + gs = '0$gs'; + gs = gs.substring(gs.length - 2); + bs = '0$bs'; + bs = bs.substring(bs.length - 2); + return '#$rs$gs$bs'; +} +`, + ); + const code = `${functionName}(${colour1}, ${colour2}, ${ratio})`; + return [code, DartOrder.UNARY_POSTFIX]; +} + +/** + * Lua generator definition. + * + * @param block The Block instance to generate code for. + * @param generator The LuaGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toLua( + block: Block, + generator: LuaGenerator, +): [string, LuaOrder] { + // Blend two colours together. + const functionName = generator.provideFunction_( + 'colour_blend', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}(colour1, colour2, ratio) + local r1 = tonumber(string.sub(colour1, 2, 3), 16) + local r2 = tonumber(string.sub(colour2, 2, 3), 16) + local g1 = tonumber(string.sub(colour1, 4, 5), 16) + local g2 = tonumber(string.sub(colour2, 4, 5), 16) + local b1 = tonumber(string.sub(colour1, 6, 7), 16) + local b2 = tonumber(string.sub(colour2, 6, 7), 16) + local ratio = math.min(1, math.max(0, ratio)) + local r = math.floor(r1 * (1 - ratio) + r2 * ratio + .5) + local g = math.floor(g1 * (1 - ratio) + g2 * ratio + .5) + local b = math.floor(b1 * (1 - ratio) + b2 * ratio + .5) + return string.format("#%02x%02x%02x", r, g, b) +end +`, + ); + const colour1 = + generator.valueToCode(block, 'COLOUR1', LuaOrder.NONE) || "'#000000'"; + const colour2 = + generator.valueToCode(block, 'COLOUR2', LuaOrder.NONE) || "'#000000'"; + const ratio = generator.valueToCode(block, 'RATIO', LuaOrder.NONE) || 0; + const code = `${functionName}(${colour1}, ${colour2}, ${ratio})`; + return [code, LuaOrder.HIGH]; +} + +/** + * PHP generator definition. + * + * @param block The Block instance to generate code for. + * @param generator The PhpGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPhp( + block: Block, + generator: PhpGenerator, +): [string, PhpOrder] { + // Blend two colours together. + const colour1 = + generator.valueToCode(block, 'COLOUR1', PhpOrder.NONE) || "'#000000'"; + const colour2 = + generator.valueToCode(block, 'COLOUR2', PhpOrder.NONE) || "'#000000'"; + const ratio = generator.valueToCode(block, 'RATIO', PhpOrder.NONE) || 0.5; + const functionName = generator.provideFunction_( + 'colour_blend', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}($c1, $c2, $ratio) { + $ratio = max(min($ratio, 1), 0); + $r1 = hexdec(substr($c1, 1, 2)); + $g1 = hexdec(substr($c1, 3, 2)); + $b1 = hexdec(substr($c1, 5, 2)); + $r2 = hexdec(substr($c2, 1, 2)); + $g2 = hexdec(substr($c2, 3, 2)); + $b2 = hexdec(substr($c2, 5, 2)); + $r = round($r1 * (1 - $ratio) + $r2 * $ratio); + $g = round($g1 * (1 - $ratio) + $g2 * $ratio); + $b = round($b1 * (1 - $ratio) + $b2 * $ratio); + $hex = '#'; + $hex .= str_pad(dechex($r), 2, '0', STR_PAD_LEFT); + $hex .= str_pad(dechex($g), 2, '0', STR_PAD_LEFT); + $hex .= str_pad(dechex($b), 2, '0', STR_PAD_LEFT); + return $hex; +} +`, + ); + const code = `${functionName}(${colour1}, ${colour2}, ${ratio})`; + return [code, PhpOrder.FUNCTION_CALL]; +} + +/** + * Python generator definition. + * + * @param block The Block instance to generate code for. + * @param generator The PythonGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPython( + block: Block, + generator: PythonGenerator, +): [string, PythonOrder] { + // Blend two colours together. + const functionName = generator.provideFunction_( + 'colour_blend', + ` +def ${generator.FUNCTION_NAME_PLACEHOLDER_}(colour1, colour2, ratio): + r1, r2 = int(colour1[1:3], 16), int(colour2[1:3], 16) + g1, g2 = int(colour1[3:5], 16), int(colour2[3:5], 16) + b1, b2 = int(colour1[5:7], 16), int(colour2[5:7], 16) + ratio = min(1, max(0, ratio)) + r = round(r1 * (1 - ratio) + r2 * ratio) + g = round(g1 * (1 - ratio) + g2 * ratio) + b = round(b1 * (1 - ratio) + b2 * ratio) + return '#%02x%02x%02x' % (r, g, b) +`, + ); + const colour1 = + generator.valueToCode(block, 'COLOUR1', PythonOrder.NONE) || "'#000000'"; + const colour2 = + generator.valueToCode(block, 'COLOUR2', PythonOrder.NONE) || "'#000000'"; + const ratio = generator.valueToCode(block, 'RATIO', PythonOrder.NONE) || 0; + const code = `${functionName}(${colour1}, ${colour2}, ${ratio})`; + return [code, PythonOrder.FUNCTION_CALL]; +} + +const definitionsDict = BlocklyCommon.createBlockDefinitionsFromJsonArray([ + jsonDefinition, +]); + +/** The colour_blend BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `colour_blend` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}) { + registerFieldColour(); + BlocklyCommon.defineBlocks(definitionsDict); + if (gens.javascript) gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + if (gens.dart) { + gens.dart.forBlock[BLOCK_NAME] = toDart; + gens.dart.addReservedWords('Math'); + } + if (gens.lua) gens.lua.forBlock[BLOCK_NAME] = toLua; + if (gens.php) gens.php.forBlock[BLOCK_NAME] = toPhp; + if (gens.python) gens.python.forBlock[BLOCK_NAME] = toPython; +} diff --git a/packages/plugins/field-colour/src/blocks/colourPicker.ts b/packages/plugins/field-colour/src/blocks/colourPicker.ts new file mode 100644 index 00000000000..23c6fcc95ef --- /dev/null +++ b/packages/plugins/field-colour/src/blocks/colourPicker.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Block, common as BlocklyCommon} from 'blockly/core'; +import { + JavascriptGenerator, + Order as JavascriptOrder, +} from 'blockly/javascript'; +import {DartGenerator, Order as DartOrder} from 'blockly/dart'; +import {LuaGenerator, Order as LuaOrder} from 'blockly/lua'; +import {PhpGenerator, Order as PhpOrder} from 'blockly/php'; +import {PythonGenerator, Order as PythonOrder} from 'blockly/python'; +import {registerFieldColour} from '../field_colour'; +import {Generators} from './generatorsType'; + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'colour_picker'; + +// Block for colour picker. +const jsonDefinition = { + type: BLOCK_NAME, + message0: '%1', + args0: [ + { + type: 'field_colour', + name: 'COLOUR', + colour: '#ff0000', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_PICKER_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_PICKER_TOOLTIP}', + extensions: ['parent_tooltip_when_inline'], +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript( + block: Block, + generator: JavascriptGenerator, +): [string, JavascriptOrder] { + // Colour picker. + const code = generator.quote_(block.getFieldValue('COLOUR')); + return [code, JavascriptOrder.ATOMIC]; +} + +/** + * Dart block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The DartGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toDart( + block: Block, + generator: DartGenerator, +): [string, DartOrder] { + // Colour picker. + const code = generator.quote_(block.getFieldValue('COLOUR')); + return [code, DartOrder.ATOMIC]; +} + +/** + * Lua block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The LuaGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toLua( + block: Block, + generator: LuaGenerator, +): [string, LuaOrder] { + // Colour picker. + const code = generator.quote_(block.getFieldValue('COLOUR')); + return [code, LuaOrder.ATOMIC]; +} + +/** + * PHP block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The PhpGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPhp( + block: Block, + generator: PhpGenerator, +): [string, PhpOrder] { + // Colour picker. + const code = generator.quote_(block.getFieldValue('COLOUR')); + return [code, PhpOrder.ATOMIC]; +} + +/** + * Python block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The PythonGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPython( + block: Block, + generator: PythonGenerator, +): [string, PythonOrder] { + // Colour picker. + const code = generator.quote_(block.getFieldValue('COLOUR')); + return [code, PythonOrder.ATOMIC]; +} + +const definitionsDict = BlocklyCommon.createBlockDefinitionsFromJsonArray([ + jsonDefinition, +]); + +/** The colour_picker BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `colour_picker` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}) { + registerFieldColour(); + BlocklyCommon.defineBlocks(definitionsDict); + if (gens.javascript) gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + if (gens.dart) gens.dart.forBlock[BLOCK_NAME] = toDart; + if (gens.lua) gens.lua.forBlock[BLOCK_NAME] = toLua; + if (gens.php) gens.php.forBlock[BLOCK_NAME] = toPhp; + if (gens.python) gens.python.forBlock[BLOCK_NAME] = toPython; +} diff --git a/packages/plugins/field-colour/src/blocks/colourRandom.ts b/packages/plugins/field-colour/src/blocks/colourRandom.ts new file mode 100644 index 00000000000..687ab02168d --- /dev/null +++ b/packages/plugins/field-colour/src/blocks/colourRandom.ts @@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Block, common as BlocklyCommon} from 'blockly/core'; +import { + JavascriptGenerator, + Order as JavascriptOrder, +} from 'blockly/javascript'; +import {DartGenerator, Order as DartOrder} from 'blockly/dart'; +import {LuaGenerator, Order as LuaOrder} from 'blockly/lua'; +import {PhpGenerator, Order as PhpOrder} from 'blockly/php'; +import {PythonGenerator, Order as PythonOrder} from 'blockly/python'; +import {registerFieldColour} from '../field_colour'; +import {Generators} from './generatorsType'; + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'colour_random'; + +// Block for random colour. +const jsonDefinition = { + type: BLOCK_NAME, + message0: '%{BKY_COLOUR_RANDOM_TITLE}', + output: 'Colour', + helpUrl: '%{BKY_COLOUR_RANDOM_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_RANDOM_TOOLTIP}', +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript( + block: Block, + generator: JavascriptGenerator, +): [string, JavascriptOrder] { + // Generate a random colour. + const functionName = generator.provideFunction_( + 'colourRandom', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}() { + var num = Math.floor(Math.random() * 0x1000000); + return '#' + ('00000' + num.toString(16)).substr(-6); +} +`, + ); + const code = functionName + '()'; + return [code, JavascriptOrder.FUNCTION_CALL]; +} + +/** + * Dart block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The DartGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toDart( + block: Block, + generator: DartGenerator, +): [string, DartOrder] { + // Generate a random colour. + // TODO(#7600): find better approach than casting to any to override + // CodeGenerator declaring .definitions protected. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (generator as any).definitions_['import_dart_math'] = + "import 'dart:math' as Math;"; + const functionName = generator.provideFunction_( + 'colour_random', + ` +String ${generator.FUNCTION_NAME_PLACEHOLDER_}() { + String hex = '0123456789abcdef'; + var rnd = new Math.Random(); + return '#\${hex[rnd.nextInt(16)]}\${hex[rnd.nextInt(16)]}' + '\${hex[rnd.nextInt(16)]}\${hex[rnd.nextInt(16)]}' + '\${hex[rnd.nextInt(16)]}\${hex[rnd.nextInt(16)]}'; +} +`, + ); + const code = functionName + '()'; + return [code, DartOrder.UNARY_POSTFIX]; +} + +/** + * Lua block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The LuaGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toLua( + block: Block, + generator: LuaGenerator, +): [string, LuaOrder] { + // Generate a random colour. + const code = 'string.format("#%06x", math.random(0, 2^24 - 1))'; + return [code, LuaOrder.HIGH]; +} + +/** + * PHP block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The PhpGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPhp( + block: Block, + generator: PhpGenerator, +): [string, PhpOrder] { + // Generate a random colour. + const functionName = generator.provideFunction_( + 'colour_random', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}() { + return '#' . str_pad(dechex(mt_rand(0, 0xFFFFFF)), 6, '0', STR_PAD_LEFT); +} +`, + ); + const code = functionName + '()'; + return [code, PhpOrder.FUNCTION_CALL]; +} + +/** + * Python block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The PythonGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPython( + block: Block, + generator: PythonGenerator, +): [string, PythonOrder] { + // Generate a random colour. + // TODO(#7600): find better approach than casting to any to override + // CodeGenerator declaring .definitions protected. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (generator as any).definitions_['import_random'] = 'import random'; + const code = "'#%06x' % random.randint(0, 2**24 - 1)"; + return [code, PythonOrder.FUNCTION_CALL]; +} + +const definitionsDict = BlocklyCommon.createBlockDefinitionsFromJsonArray([ + jsonDefinition, +]); + +/** The colour_random BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `colour_picker` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}) { + registerFieldColour(); + BlocklyCommon.defineBlocks(definitionsDict); + if (gens.javascript) gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + if (gens.dart) gens.dart.forBlock[BLOCK_NAME] = toDart; + if (gens.lua) gens.lua.forBlock[BLOCK_NAME] = toLua; + if (gens.php) gens.php.forBlock[BLOCK_NAME] = toPhp; + if (gens.python) gens.python.forBlock[BLOCK_NAME] = toPython; +} diff --git a/packages/plugins/field-colour/src/blocks/colourRgb.ts b/packages/plugins/field-colour/src/blocks/colourRgb.ts new file mode 100644 index 00000000000..46ee55045aa --- /dev/null +++ b/packages/plugins/field-colour/src/blocks/colourRgb.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Block, common as BlocklyCommon} from 'blockly/core'; +import { + JavascriptGenerator, + Order as JavascriptOrder, +} from 'blockly/javascript'; +import {DartGenerator, Order as DartOrder} from 'blockly/dart'; +import {LuaGenerator, Order as LuaOrder} from 'blockly/lua'; +import {PhpGenerator, Order as PhpOrder} from 'blockly/php'; +import {PythonGenerator, Order as PythonOrder} from 'blockly/python'; +import {registerFieldColour} from '../field_colour'; +import {Generators} from './generatorsType'; + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'colour_rgb'; + +// Block for composing a colour from RGB components. +const jsonDefinition = { + type: BLOCK_NAME, + message0: + '%{BKY_COLOUR_RGB_TITLE} %{BKY_COLOUR_RGB_RED} %1 %{BKY_COLOUR_RGB_GREEN} %2 %{BKY_COLOUR_RGB_BLUE} %3', + args0: [ + { + type: 'input_value', + name: 'RED', + check: 'Number', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'GREEN', + check: 'Number', + align: 'RIGHT', + }, + { + type: 'input_value', + name: 'BLUE', + check: 'Number', + align: 'RIGHT', + }, + ], + output: 'Colour', + helpUrl: '%{BKY_COLOUR_RGB_HELPURL}', + style: 'colour_blocks', + tooltip: '%{BKY_COLOUR_RGB_TOOLTIP}', +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript( + block: Block, + generator: JavascriptGenerator, +): [string, JavascriptOrder] { + // Compose a colour from RGB components expressed as percentages. + const red = generator.valueToCode(block, 'RED', JavascriptOrder.NONE) || 0; + const green = + generator.valueToCode(block, 'GREEN', JavascriptOrder.NONE) || 0; + const blue = generator.valueToCode(block, 'BLUE', JavascriptOrder.NONE) || 0; + const functionName = generator.provideFunction_( + 'colourRgb', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}(r, g, b) { + r = Math.max(Math.min(Number(r), 100), 0) * 2.55; + g = Math.max(Math.min(Number(g), 100), 0) * 2.55; + b = Math.max(Math.min(Number(b), 100), 0) * 2.55; + r = ('0' + (Math.round(r) || 0).toString(16)).slice(-2); + g = ('0' + (Math.round(g) || 0).toString(16)).slice(-2); + b = ('0' + (Math.round(b) || 0).toString(16)).slice(-2); + return '#' + r + g + b; +} +`, + ); + const code = `${functionName}(${red}, ${green}, ${blue})`; + return [code, JavascriptOrder.FUNCTION_CALL]; +} + +/** + * Dart block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The DartGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toDart( + block: Block, + generator: DartGenerator, +): [string, DartOrder] { + // Compose a colour from RGB components expressed as percentages. + const red = generator.valueToCode(block, 'RED', DartOrder.NONE) || 0; + const green = generator.valueToCode(block, 'GREEN', DartOrder.NONE) || 0; + const blue = generator.valueToCode(block, 'BLUE', DartOrder.NONE) || 0; + + // TODO(#7600): find better approach than casting to any to override + // CodeGenerator declaring .definitions protected. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (generator as any).definitions_['import_dart_math'] = + "import 'dart:math' as Math;"; + const functionName = generator.provideFunction_( + 'colour_rgb', + ` +String ${generator.FUNCTION_NAME_PLACEHOLDER_}(num r, num g, num b) { + num rn = (Math.max(Math.min(r, 100), 0) * 2.55).round(); + String rs = rn.toInt().toRadixString(16); + rs = '0$rs'; + rs = rs.substring(rs.length - 2); + num gn = (Math.max(Math.min(g, 100), 0) * 2.55).round(); + String gs = gn.toInt().toRadixString(16); + gs = '0$gs'; + gs = gs.substring(gs.length - 2); + num bn = (Math.max(Math.min(b, 100), 0) * 2.55).round(); + String bs = bn.toInt().toRadixString(16); + bs = '0$bs'; + bs = bs.substring(bs.length - 2); + return '#$rs$gs$bs'; +} +`, + ); + const code = `${functionName}(${red}, ${green}, ${blue})`; + return [code, DartOrder.UNARY_POSTFIX]; +} + +/** + * Lua block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The LuaGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toLua( + block: Block, + generator: LuaGenerator, +): [string, LuaOrder] { + // Compose a colour from RGB components expressed as percentages. + const functionName = generator.provideFunction_( + 'colour_rgb', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}(r, g, b) + r = math.floor(math.min(100, math.max(0, r)) * 2.55 + .5) + g = math.floor(math.min(100, math.max(0, g)) * 2.55 + .5) + b = math.floor(math.min(100, math.max(0, b)) * 2.55 + .5) + return string.format("#%02x%02x%02x", r, g, b) +end +`, + ); + const red = generator.valueToCode(block, 'RED', LuaOrder.NONE) || 0; + const green = generator.valueToCode(block, 'GREEN', LuaOrder.NONE) || 0; + const blue = generator.valueToCode(block, 'BLUE', LuaOrder.NONE) || 0; + const code = `${functionName}(${red}, ${green}, ${blue})`; + return [code, LuaOrder.HIGH]; +} + +/** + * PHP block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The PhpGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPhp( + block: Block, + generator: PhpGenerator, +): [string, PhpOrder] { + // Compose a colour from RGB components expressed as percentages. + const red = generator.valueToCode(block, 'RED', PhpOrder.NONE) || 0; + const green = generator.valueToCode(block, 'GREEN', PhpOrder.NONE) || 0; + const blue = generator.valueToCode(block, 'BLUE', PhpOrder.NONE) || 0; + const functionName = generator.provideFunction_( + 'colour_rgb', + ` +function ${generator.FUNCTION_NAME_PLACEHOLDER_}($r, $g, $b) { + $r = round(max(min($r, 100), 0) * 2.55); + $g = round(max(min($g, 100), 0) * 2.55); + $b = round(max(min($b, 100), 0) * 2.55); + $hex = '#'; + $hex .= str_pad(dechex($r), 2, '0', STR_PAD_LEFT); + $hex .= str_pad(dechex($g), 2, '0', STR_PAD_LEFT); + $hex .= str_pad(dechex($b), 2, '0', STR_PAD_LEFT); + return $hex; +} +`, + ); + const code = `${functionName}(${red}, ${green}, ${blue})`; + return [code, PhpOrder.FUNCTION_CALL]; +} + +/** + * Python block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The PythonGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPython( + block: Block, + generator: PythonGenerator, +): [string, PythonOrder] { + // Compose a colour from RGB components expressed as percentages. + const functionName = generator.provideFunction_( + 'colour_rgb', + ` +def ${generator.FUNCTION_NAME_PLACEHOLDER_}(r, g, b): + r = round(min(100, max(0, r)) * 2.55) + g = round(min(100, max(0, g)) * 2.55) + b = round(min(100, max(0, b)) * 2.55) + return '#%02x%02x%02x' % (r, g, b) +`, + ); + const r = generator.valueToCode(block, 'RED', PythonOrder.NONE) || 0; + const g = generator.valueToCode(block, 'GREEN', PythonOrder.NONE) || 0; + const b = generator.valueToCode(block, 'BLUE', PythonOrder.NONE) || 0; + const code = functionName + '(' + r + ', ' + g + ', ' + b + ')'; + return [code, PythonOrder.FUNCTION_CALL]; +} + +const definitionsDict = BlocklyCommon.createBlockDefinitionsFromJsonArray([ + jsonDefinition, +]); + +/** The colour_rgb BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `colour_rgb` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}) { + registerFieldColour(); + BlocklyCommon.defineBlocks(definitionsDict); + if (gens.javascript) gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + if (gens.dart) { + gens.dart.forBlock[BLOCK_NAME] = toDart; + gens.dart.addReservedWords('Math'); + } + if (gens.lua) gens.lua.forBlock[BLOCK_NAME] = toLua; + if (gens.php) gens.php.forBlock[BLOCK_NAME] = toPhp; + if (gens.python) gens.python.forBlock[BLOCK_NAME] = toPython; +} diff --git a/packages/plugins/field-colour/src/blocks/generatorsType.ts b/packages/plugins/field-colour/src/blocks/generatorsType.ts new file mode 100644 index 00000000000..e9d2df4609c --- /dev/null +++ b/packages/plugins/field-colour/src/blocks/generatorsType.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JavascriptGenerator} from 'blockly/javascript'; +import type {DartGenerator} from 'blockly/dart'; +import type {LuaGenerator} from 'blockly/lua'; +import type {PhpGenerator} from 'blockly/php'; +import type {PythonGenerator} from 'blockly/python'; + +/** + * An object containing zero or more generators. This is passed + * to block installation functions so that they may install + * per-block generators on any languages they support. + */ +export interface Generators { + javascript?: JavascriptGenerator; + dart?: DartGenerator; + lua?: LuaGenerator; + php?: PhpGenerator; + python?: PythonGenerator; +} diff --git a/packages/plugins/field-colour/src/field_colour.ts b/packages/plugins/field-colour/src/field_colour.ts new file mode 100644 index 00000000000..adb3396dec7 --- /dev/null +++ b/packages/plugins/field-colour/src/field_colour.ts @@ -0,0 +1,524 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Colour input field. + */ + +import * as Blockly from 'blockly/core'; +import { + FieldGridDropdown, + FieldGridDropdownConfig, + FieldGridDropdownFromJsonConfig, +} from '@blockly/field-grid-dropdown'; + +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * An array of colour strings for the palette. + * Copied from goog.ui.ColorPicker.SIMPLE_GRID_COLORS + */ +const DEFAULT_COLOURS: Record = { + // greys + '#ffffff': 'White', + '#cccccc': 'Light Grey', + '#c0c0c0': 'Silver', + '#999999': 'Grey', + '#666666': 'Dark Grey', + '#333333': 'Charcoal', + '#000000': 'Black', + // reds + '#ffcccc': 'Pale Red', + '#ff6666': 'Light Red', + '#ff0000': 'Red', + '#cc0000': 'Dark Red', + '#990000': 'Darker Red', + '#660000': 'Maroon', + '#330000': 'Darkest Red', + // oranges + '#ffcc99': 'Peach', + '#ff9966': 'Light Orange', + '#ff9900': 'Orange', + '#ff6600': 'Bright Orange', + '#cc6600': 'Dark Orange', + '#993300': 'Rust', + '#663300': 'Brown', + // yellows + '#ffff99': 'Light Yellow', + '#ffff66': 'Pale Yellow', + '#ffcc66': 'Light Gold', + '#ffcc33': 'Gold', + '#cc9933': 'Goldenrod', + '#996633': 'Light Brown', + '#663333': 'Dark Brown', + // olives + '#ffffcc': 'Cream', + '#ffff33': 'Bright Yellow', + '#ffff00': 'Yellow', + '#ffcc00': 'Amber', + '#999900': 'Olive', + '#666600': 'Dark Olive', + '#333300': 'Darkest Olive', + // greens + '#99ff99': 'Light Green', + '#66ff99': 'Mint', + '#33ff33': 'Bright Green', + '#33cc00': 'Green', + '#009900': 'Dark Green', + '#006600': 'Darker Green', + '#003300': 'Darkest Green', + // turquoises + '#99ffff': 'Pale Cyan', + '#33ffff': 'Bright Cyan', + '#66cccc': 'Light Teal', + '#00cccc': 'Turquoise', + '#339999': 'Teal', + '#336666': 'Dark Teal', + '#003333': 'Darkest Teal', + // blues + '#ccffff': 'Pale Aqua', + '#66ffff': 'Light Cyan', + '#33ccff': 'Sky Blue', + '#3366ff': 'Blue', + '#3333ff': 'Bright Blue', + '#000099': 'Dark Blue', + '#000066': 'Navy', + // purples + '#ccccff': 'Pale Periwinkle', + '#9999ff': 'Periwinkle', + '#6666cc': 'Slate Blue', + '#6633ff': 'Violet', + '#6600cc': 'Purple', + '#333399': 'Indigo', + '#330099': 'Dark Indigo', + // violets + '#ffccff': 'Pale Magenta', + '#ff99ff': 'Light Magenta', + '#cc66cc': 'Orchid', + '#cc33cc': 'Magenta', + '#993399': 'Dark Magenta', + '#663366': 'Plum', + '#330033': 'Darkest Purple', +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * Class for a colour input field. + */ +export class FieldColour extends FieldGridDropdown { + /** + * Used to tell if the field needs to be rendered the next time the block is + * rendered. Colour fields are statically sized, and only need to be + * rendered at initialization. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override isDirty_ = false; + + protected override ariaTypeName = Blockly.Msg['ARIA_TYPE_FIELD_COLOUR']; + + /** + * @param value The initial value of the field. Should be in '#rrggbb' + * format. Defaults to the first value in the default colour array. Also + * accepts Field.SKIP_SETUP if you wish to skip setup (only used by + * subclasses that want to handle configuration and setting the field + * value after their own constructors have run). + * @param validator A function that is called to validate changes to the + * field's value. Takes in a colour string & returns a validated colour + * string ('#rrggbb' format), or null to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/colour} + * for a list of properties this parameter supports. + */ + constructor( + value?: string | typeof Blockly.Field.SKIP_SETUP, + validator?: FieldColourValidator, + config?: FieldColourConfig, + ) { + const swatches = makeSwatches( + config?.colourOptions ?? Object.keys(DEFAULT_COLOURS), + config?.colourTitles, + ); + super(swatches, validator, {...config, columns: config?.columns ?? 7}); + + if (value === Blockly.Field.SKIP_SETUP) return; + this.setValue(value); + } + + /** + * FieldDropdown has complex behaviors for normalizing options that aren't + * applicable here. Instead, just return the options as-is. + * + * @param options The options (colour swatches) to normalize. + * @returns The colour swatches as-is. + */ + protected override trimOptions(options: Blockly.MenuOption[]) { + return {options}; + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override configure_(config: FieldColourConfig) { + super.configure_(config); + if (config.colourOptions) { + this.setColours(config.colourOptions, config.colourTitles); + } + } + + /** + * Create the block UI for this colour field. + * + * @internal + */ + override initView() { + const constants = this.getConstants(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!constants) throw Error('Constants not found'); + this.size_ = new Blockly.utils.Size( + constants.FIELD_COLOUR_DEFAULT_WIDTH, + constants.FIELD_COLOUR_DEFAULT_HEIGHT, + ); + if (this.isFullBlockField()) { + this.clickTarget_ = (this.sourceBlock_ as Blockly.BlockSvg).getSvgRoot(); + } else { + this.createBorderRect_(); + this.getBorderRect().style['fillOpacity'] = '1'; + } + + if (this.fieldGroup_) { + this.fieldGroup_.classList.add('blocklyField'); + } + + this.recomputeAriaContext(); + } + + /** + * Shows the colour picker dropdown attached to the field. + * + * @param e The event that triggered display of the colour picker dropdown. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override showEditor_(e?: MouseEvent) { + super.showEditor_(e); + Blockly.DropDownDiv.getContentDiv().classList.add('blocklyFieldColour'); + Blockly.DropDownDiv.repositionForWindowResize(); + } + + /** + * Defines whether this field should take up the full block or not. + * + * @returns True if this field should take up the full block. False otherwise. + */ + override isFullBlockField(): boolean { + const block = this.getSourceBlock(); + if (!block) throw new Blockly.UnattachedFieldError(); + + const constants = this.getConstants(); + return ( + this.blockIsSimpleReporter() && + Boolean(constants?.FIELD_COLOUR_FULL_BLOCK) + ); + } + + /** + * @returns True if the source block is a value block with a single editable + * field. + * @internal + */ + blockIsSimpleReporter(): boolean { + const block = this.getSourceBlock(); + if (!block) throw new Blockly.UnattachedFieldError(); + + if (!block.outputConnection) return false; + + for (const input of block.inputList) { + if (input.connection || input.fieldRow.length > 1) return false; + } + return true; + } + + /** + * Updates text field to match the colour/style of the block. + * + * @internal + */ + override applyColour() { + const block = this.getSourceBlock() as Blockly.BlockSvg | null; + if (!block) throw new Blockly.UnattachedFieldError(); + + if (!this.fieldGroup_) return; + + const borderRect = this.borderRect_; + + if (!this.isFullBlockField()) { + if (!borderRect) { + throw new Error('The border rect has not been initialized'); + } + borderRect.style.display = 'block'; + borderRect.style.fill = this.getValue() as string; + } else { + // In general, do *not* let fields control the color of blocks. Having the + // field control the color is unexpected, and could have performance + // impacts. + block.pathObject.svgPath.setAttribute('fill', this.getValue() as string); + } + this.recomputeAriaContext(); + } + + /** + * Returns the height and width of the field. + * + * This should *in general* be the only place render_ gets called from. + * + * @returns Height and width. + */ + override getSize(): Blockly.utils.Size { + if (this.getConstants()?.FIELD_COLOUR_FULL_BLOCK) { + // In general, do *not* let fields control the color of blocks. Having the + // field control the color is unexpected, and could have performance + // impacts. + // Full block fields have more control of the block than they should + // (i.e. updating fill colour) so they always need to be rerendered. + this.render_(); + this.isDirty_ = false; + } + return super.getSize(); + } + + /** + * Updates the colour of the block to reflect whether this is a full + * block field or not. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override render_() { + this.updateSize_(); + + const block = this.getSourceBlock() as Blockly.BlockSvg | null; + if (!block) throw new Blockly.UnattachedFieldError(); + // Calling applyColour updates the UI (full-block vs non-full-block) for the + // colour field, and the colour of the field/block. + block.applyColour(); + } + + /** + * Updates the size of the field based on whether it is a full block field + * or not. + * + * @param margin margin to use when positioning the field. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected updateSize_(margin?: number) { + const constants = this.getConstants(); + if (!constants) return; + let totalWidth; + let totalHeight; + if (this.isFullBlockField()) { + const xOffset = margin ?? 0; + totalWidth = xOffset * 2; + totalHeight = constants.FIELD_TEXT_HEIGHT; + } else { + totalWidth = constants.FIELD_COLOUR_DEFAULT_WIDTH; + totalHeight = constants.FIELD_COLOUR_DEFAULT_HEIGHT; + } + + this.size_.height = totalHeight; + this.size_.width = totalWidth; + + this.positionBorderRect_(); + } + + /** + * Ensure that the input value is a valid colour. + * + * @param newValue The input value. + * @returns A valid colour, or null if invalid. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_( + newValue: string, + ): string | null | undefined; + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_(newValue?: string): string | null; + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doClassValidation_( + newValue?: string, + ): string | null | undefined { + if (typeof newValue !== 'string') { + return null; + } + return Blockly.utils.colour.parse(newValue); + } + + /** + * Get the text for this field. Used when the block is collapsed. + * + * @returns Text representing the value of this field. + */ + override getText(): string { + let colour = this.value_ as string; + // Try to use #rgb format if possible, rather than #rrggbb. + if (/^#(.)\1(.)\2(.)\3$/.test(colour)) { + colour = '#' + colour[1] + colour[3] + colour[5]; + } + return colour; + } + + /** + * Set a custom colour grid for this field. + * + * @param colours Array of colours for this block, or null to use default + * (FieldColour.COLOURS). + * @param titles Optional array of colour tooltips, or null to use default + * (FieldColour.TITLES). + * @returns Returns itself (for method chaining). + */ + setColours(colours: string[], titles?: string[]): FieldColour { + const swatches = makeSwatches(colours, titles); + this.setOptions(swatches); + return this; + } + + /** + * Construct a FieldColour from a JSON arg object. + * + * @param options A JSON object with options (colour). + * @returns The new field instance. + * @nocollapse + * @internal + */ + static fromJson(options: FieldColourFromJsonConfig): FieldColour { + // `this` might be a subclass of FieldColour if that class doesn't override + // the static fromJson method. + return new this(options.colour, undefined, options); + } +} + +/** + * Creates a set of divs representing colour swatches for use in the picker. + * + * @param colours An array of colours to create swatches for. The colours must + * be any legal CSS colour specifier. + * @param titles A corresponding array of titles to be displayed as tooltips on + * the colour swatches. + * @returns An array of pairs of DOM elements representing colour swatches and + * their corresponding colour. + */ +function makeSwatches( + colours: string[], + titles?: string[], +): Blockly.MenuOption[] { + return colours.map((colour, index) => { + const swatch = document.createElement('div'); + swatch.className = 'blocklyColourSwatch'; + swatch.style.backgroundColor = colour; + + if (titles && index < titles.length) { + swatch.title = titles[index]; + } + return [swatch, colour, titles?.[index] ?? DEFAULT_COLOURS[colour]]; + }); +} + +/** The default value for this field. */ +FieldColour.prototype.DEFAULT_VALUE = '#ffffff'; + +/** + * Register the field and any dependencies. + */ +export function registerFieldColour() { + Blockly.fieldRegistry.register('field_colour', FieldColour); +} + +/** + * CSS for colour picker. + */ +Blockly.Css.register(` +.blocklyFieldColour .blocklyFieldGridItemSelected, +.blocklyFieldGridItemSelected:hover { + border-color: #eee !important; + outline: 1px solid #333; + position: relative; +} + +.blocklyColourSwatch { + width: 20px; + height: 20px; +} + +.blocklyGridContainer { + padding: 0px; +} + +.blocklyFieldColour .blocklyFieldGrid { + grid-gap: 0px; + row-gap: 4px; +} + +.blocklyFieldColour .blocklyFieldGrid .blocklyGridItem { + border-radius: 0; + padding: 0; + border: 0.5px solid #888; + cursor: pointer; +} + +.blocklyFieldColour .blocklyFieldGrid .blocklyFieldGridItem { + border: 0.5px solid #888; + padding: 0; + margin: 0; + border-radius: 0; +} + +.blocklyFieldColour .blocklyFieldGrid .blocklyFieldGridItem:focus { + border-color: #eee; + box-shadow: 2px 2px 7px 2px rgba(0, 0, 0, 0.3); + position: relative; + border-radius: 0; + outline: none; +} + +.blocklyKeyboardNavigation .blocklyFieldColour .blocklyFieldGrid .blocklyFieldGridItem:focus { + outline: var(--blockly-selection-width) solid var(--blockly-active-node-color); + outline-offset: -2px; + border: none; + box-shadow: none; + border-radius: 4px; +} +`); + +/** + * Config options for the colour field. + */ +export interface FieldColourConfig extends FieldGridDropdownConfig { + colourOptions?: string[]; + colourTitles?: string[]; +} + +/** + * fromJson config options for the colour field. + */ +export interface FieldColourFromJsonConfig extends FieldGridDropdownFromJsonConfig { + colour?: string; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldColourValidator = Blockly.FieldValidator; diff --git a/packages/plugins/field-colour/src/index.ts b/packages/plugins/field-colour/src/index.ts new file mode 100644 index 00000000000..b0474b0b487 --- /dev/null +++ b/packages/plugins/field-colour/src/index.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './field_colour'; + +import * as colourPicker from './blocks/colourPicker'; +import * as colourRandom from './blocks/colourRandom'; +import * as colourRgb from './blocks/colourRgb'; +import * as colourBlend from './blocks/colourBlend'; +import {Generators} from './blocks/generatorsType'; + +// Re-export all parts of the definition. +export * as colourPicker from './blocks/colourPicker'; +export * as colourRandom from './blocks/colourRandom'; +export * as colourRgb from './blocks/colourRgb'; +export * as colourBlend from './blocks/colourBlend'; + +/** + * Install all of the blocks defined in this file and all of their + * dependencies. + * + * @param generators The CodeGenerators to install per-block + * generators on. + */ +export function installAllBlocks(generators: Generators = {}) { + colourPicker.installBlock(generators); + colourRgb.installBlock(generators); + colourRandom.installBlock(generators); + colourBlend.installBlock(generators); +} diff --git a/packages/plugins/field-colour/test/blocks_test.mocha.js b/packages/plugins/field-colour/test/blocks_test.mocha.js new file mode 100644 index 00000000000..fea374f6eba --- /dev/null +++ b/packages/plugins/field-colour/test/blocks_test.mocha.js @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import * as Blockly from 'blockly/core'; +import 'blockly/blocks'; + +import {javascriptGenerator} from 'blockly/javascript'; +import {dartGenerator} from 'blockly/dart'; +import {phpGenerator} from 'blockly/php'; +import {pythonGenerator} from 'blockly/python'; +import {luaGenerator} from 'blockly/lua'; +import {installAllBlocks} from '../src/index'; +import {assert} from 'chai'; + +const blockJson = { + blocks: { + languageVersion: 0, + blocks: [ + { + type: 'colour_picker', + x: 13, + y: 13, + fields: { + COLOUR: '#ff0000', + }, + }, + { + type: 'colour_random', + x: 13, + y: 113, + }, + { + type: 'colour_rgb', + x: 13, + y: 263, + inputs: { + RED: { + shadow: { + type: 'math_number', + fields: { + NUM: 0, + }, + }, + }, + GREEN: { + shadow: { + type: 'math_number', + fields: { + NUM: 1, + }, + }, + }, + BLUE: { + shadow: { + type: 'math_number', + fields: { + NUM: 20, + }, + }, + }, + }, + }, + { + type: 'colour_blend', + x: 13, + y: 363, + inputs: { + COLOUR1: { + shadow: { + type: 'colour_picker', + fields: { + COLOUR: '#ff0000', + }, + }, + }, + COLOUR2: { + shadow: { + type: 'colour_picker', + fields: { + COLOUR: '#3333ff', + }, + }, + }, + RATIO: { + shadow: { + type: 'math_number', + fields: { + NUM: 0.5, + }, + }, + }, + }, + }, + { + type: 'colour_picker', + x: 13, + y: 63, + fields: { + COLOUR: '#3333ff', + }, + }, + { + type: 'colour_rgb', + x: 13, + y: 163, + inputs: { + RED: { + shadow: { + type: 'math_number', + fields: { + NUM: 100, + }, + }, + }, + GREEN: { + shadow: { + type: 'math_number', + fields: { + NUM: 50, + }, + }, + }, + BLUE: { + shadow: { + type: 'math_number', + fields: { + NUM: 0, + }, + }, + }, + }, + }, + { + type: 'colour_blend', + x: 13, + y: 463, + inputs: { + COLOUR1: { + shadow: { + type: 'colour_picker', + fields: { + COLOUR: '#000000', + }, + }, + }, + COLOUR2: { + shadow: { + type: 'colour_picker', + fields: { + COLOUR: '#ffffff', + }, + }, + }, + RATIO: { + shadow: { + type: 'math_number', + fields: { + NUM: 0.3, + }, + }, + }, + }, + }, + ], + }, +}; + +/** + * Assert that the generated code matches the golden code for the specified + * language. + * @param {string} suffix The suffix of the golden file. + * @param {string} generated The generated code to compare against the + * golden file. + */ +function checkResult(suffix, generated) { + const fileName = `test/golden/golden.${suffix}`; + const goldenContents = fs.readFileSync(fileName); + // Normalize the line feeds. + const normalized = goldenContents.toString().replace(/(?:\r\n|\r|\n)/g, '\n'); + assert.equal(generated, normalized); +} + +suite('Colour Block Generators', function () { + suiteSetup(function () { + installAllBlocks({ + javascript: javascriptGenerator, + dart: dartGenerator, + lua: luaGenerator, + python: pythonGenerator, + php: phpGenerator, + }); + }); + setup(function () { + this.jsdomCleanup = require('jsdom-global')( + '
', + ); + this.workspace = new Blockly.Workspace(); + Blockly.serialization.workspaces.load(blockJson, this.workspace); + }); + test('JavaScript', function () { + const generated = javascriptGenerator.workspaceToCode(this.workspace); + checkResult('js', generated); + }); + test('Dart', function () { + const generated = dartGenerator.workspaceToCode(this.workspace); + checkResult('dart', generated); + }); + test('Lua', function () { + const generated = luaGenerator.workspaceToCode(this.workspace); + checkResult('lua', generated); + }); + test('Python', function () { + const generated = pythonGenerator.workspaceToCode(this.workspace); + checkResult('py', generated); + }); + test('PHP', function () { + const generated = phpGenerator.workspaceToCode(this.workspace); + checkResult('php', generated); + }); + teardown(function () { + this.jsdomCleanup(); + this.workspace.dispose(); + }); +}); diff --git a/packages/plugins/field-colour/test/field_colour_test.mocha.js b/packages/plugins/field-colour/test/field_colour_test.mocha.js new file mode 100644 index 00000000000..057bcab121a --- /dev/null +++ b/packages/plugins/field-colour/test/field_colour_test.mocha.js @@ -0,0 +1,389 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const {testHelpers} = require('@blockly/dev-tools'); +const {FieldColour, registerFieldColour} = require('../src/index'); +const {assert} = require('chai'); + +const { + assertFieldValue, + FieldCreationTestCase, + FieldValueTestCase, + runConstructorSuiteTests, + runFromJsonSuiteTests, + runSetValueTests, +} = testHelpers; + +suite('FieldColour', function () { + setup(function () { + registerFieldColour(); + this.jsdomCleanup = require('jsdom-global')( + '
', + ); + }); + + teardown(function () { + this.jsdomCleanup(); + }); + + /** + * Configuration for field tests with invalid values. + * @type {Array} + */ + const invalidValueTestCases = [ + {title: 'Undefined', value: undefined}, + {title: 'Null', value: null}, + {title: 'NaN', value: NaN}, + {title: 'Non-Parsable String', value: 'bad-string'}, + {title: 'Integer', value: 1}, + {title: 'Float', value: 1.5}, + {title: 'Infinity', value: Infinity}, + {title: 'Negative Infinity', value: -Infinity}, + ]; + /** + * Configuration for field tests with valid values. + * @type {Array} + */ + const validValueTestCases = [ + { + title: '#AAAAAA', + value: '#AAAAAA', + expectedValue: '#aaaaaa', + expectedText: '#aaa', + }, + { + title: '#aaaaaa', + value: '#aaaaaa', + expectedValue: '#aaaaaa', + expectedText: '#aaa', + }, + { + title: '#AAAA00', + value: '#AAAA00', + expectedValue: '#aaaa00', + expectedText: '#aa0', + }, + { + title: '#aaaA00', + value: '#aaaA00', + expectedValue: '#aaaa00', + expectedText: '#aa0', + }, + { + title: '#BCBCBC', + value: '#BCBCBC', + expectedValue: '#bcbcbc', + expectedText: '#bcbcbc', + }, + { + title: '#bcbcbc', + value: '#bcbcbc', + expectedValue: '#bcbcbc', + expectedText: '#bcbcbc', + }, + { + title: '#AA0', + value: '#AA0', + expectedValue: '#aaaa00', + expectedText: '#aa0', + }, + { + title: '#aa0', + value: '#aa0', + expectedValue: '#aaaa00', + expectedText: '#aa0', + }, + { + title: 'rgb(170, 170, 0)', + value: 'rgb(170, 170, 0)', + expectedValue: '#aaaa00', + expectedText: '#aa0', + }, + { + title: 'red', + value: 'red', + expectedValue: '#ff0000', + expectedText: '#f00', + }, + ]; + const addArgsAndJson = function (testCase) { + testCase.args = [testCase.value]; + testCase.json = {colour: testCase.value}; + }; + invalidValueTestCases.forEach(addArgsAndJson); + validValueTestCases.forEach(addArgsAndJson); + + /** + * The expected default value for the field being tested. + * @type {*} + */ + const defaultFieldValue = FieldColour.prototype.DEFAULT_VALUE; + + /** + * The expected default text for the field being tested. + * @type {*} + */ + const defaultTextValue = (function () { + let expectedText = defaultFieldValue; + const m = defaultFieldValue.match(/^#(.)\1(.)\2(.)\3$/); + if (m) { + expectedText = '#' + m[1] + m[2] + m[3]; + } + return expectedText; + })(); + /** + * Asserts that the field property values are set to default. + * @param {!FieldColour} field The field to check. + */ + const assertFieldDefault = function (field) { + assertFieldValue(field, defaultFieldValue, defaultTextValue); + }; + + /** + * Asserts that the field properties are correct based on the test case. + * @param {FieldColour} field The field to check. + * @param {FieldValueTestCase} testCase The test case. + */ + const validTestCaseAssertField = function (field, testCase) { + assertFieldValue(field, testCase.expectedValue, testCase.expectedText); + }; + + runConstructorSuiteTests( + FieldColour, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + runFromJsonSuiteTests( + FieldColour, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + suite('setValue', function () { + /** + * Create a mock block that may be used as the source block for a field. + * @returns {!Object} Mock block. + */ + function createBlockMock() { + return { + id: 'test', + rendered: false, + workspace: { + rendered: false, + }, + renameVarById: Blockly.Block.prototype.renameVarById, + updateVarName: Blockly.Block.prototype.updateVarName, + isShadow: () => false, + isDeadOrDying: () => false, + }; + } + + suite('Empty -> New Value', function () { + setup(function () { + this.field = new FieldColour(); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + defaultFieldValue, + defaultTextValue, + ); + test('With source block', function () { + this.field.setSourceBlock(createBlockMock()); + this.field.setValue('#bcbcbc'); + assertFieldValue(this.field, '#bcbcbc', '#bcbcbc'); + }); + }); + suite('Value -> New Value', function () { + setup(function () { + this.field = new FieldColour('#aaaaaa'); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + '#aaaaaa', + '#aaa', + ); + test('With source block', function () { + this.field.setSourceBlock(createBlockMock()); + this.field.setValue('#bcbcbc'); + assertFieldValue(this.field, '#bcbcbc', '#bcbcbc'); + }); + }); + }); + + suite('Validators', function () { + setup(function () { + this.field = new FieldColour('#aaaaaa'); + }); + const testSuites = [ + { + title: 'Null Validator', + validator: function () { + return null; + }, + value: '#000000', + expectedValue: '#aaaaaa', + expectedText: '#aaa', + }, + { + title: 'Force Full Red Validator', + validator: function (newValue) { + return '#ff' + newValue.substr(3, 4); + }, + value: '#000000', + expectedValue: '#ff0000', + expectedText: '#f00', + }, + { + title: 'Returns Undefined Validator', + validator: function () {}, + value: '#000000', + expectedValue: '#000000', + expectedText: '#000', + }, + ]; + testSuites.forEach(function (suiteInfo) { + suite(suiteInfo.title, function () { + setup(function () { + this.field.setValidator(suiteInfo.validator); + }); + test('New Value', function () { + this.field.setValue(suiteInfo.value); + assertFieldValue( + this.field, + suiteInfo.expectedValue, + suiteInfo.expectedText, + ); + }); + }); + }); + }); + + suite('Customizations', function () { + suite('Colours and Titles', function () { + /** + * Verify that the list of colours and titles are as expected. + * @param {!FieldColour} field Field to test. + * @param {!Array[string]} expectedColours Array of colour names. + * @param {!Array[string]} expectedTitles Array of title names. + */ + function assertColoursAndTitles(field, expectedColours, expectedTitles) { + const options = field.getOptions(); + const actualColours = options.map((option) => + Blockly.utils.colour.parse(option[0].style.backgroundColor), + ); + const actualTitles = options + .map((option) => option[0].title) + .filter((title) => !!title); + assert.equal(String(actualColours), String(expectedColours)); + assert.equal(String(actualTitles), String(expectedTitles)); + } + test('JS Constructor', function () { + const field = new FieldColour('#aaaaaa', null, { + colourOptions: ['#aaaaaa'], + colourTitles: ['grey'], + }); + assertColoursAndTitles(field, ['#aaaaaa'], ['grey']); + }); + test('JSON Definition', function () { + const field = FieldColour.fromJson({ + colour: '#aaaaaa', + colourOptions: ['#aaaaaa'], + colourTitles: ['grey'], + }); + assertColoursAndTitles(field, ['#aaaaaa'], ['grey']); + }); + test('setColours', function () { + const field = new FieldColour(); + field.setColours(['#aaaaaa'], ['grey']); + assertColoursAndTitles(field, ['#aaaaaa'], ['grey']); + }); + test('Titles Undefined', function () { + const field = new FieldColour(); + field.setColours(['#aaaaaa']); + assertColoursAndTitles(field, ['#aaaaaa'], []); + }); + test('Some Titles Undefined', function () { + const field = new FieldColour(); + field.setColours(['#aaaaaa', '#ff0000'], ['grey']); + assertColoursAndTitles(field, ['#aaaaaa', '#ff0000'], ['grey']); + }); + }); + + suite('Columns', function () { + /** + * Verify that the number of columns is as expected. + * @param {!FieldColour} field Field to test. + * @param {number} expectedColumns Number of columns field should have. + */ + function assertColumns(field, expectedColumns) { + const actualColumns = field.columns || FieldColour.COLUMNS; + assert.equal(actualColumns, expectedColumns); + } + test('JS Constructor', function () { + const field = new FieldColour('#ffffff', null, {columns: 3}); + assertColumns(field, 3); + }); + test('JSON Definition', function () { + const field = FieldColour.fromJson({ + colour: '#ffffff', + columns: 3, + }); + assertColumns(field, 3); + }); + test('setColumns', function () { + const field = new FieldColour(); + field.setColumns(3); + assertColumns(field, 3); + }); + }); + }); + + suite('Serialization', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + Blockly.defineBlocksWithJsonArray([ + { + type: 'row_block', + message0: '%1', + args0: [ + { + type: 'input_value', + name: 'INPUT', + }, + ], + output: null, + }, + ]); + + this.assertValue = (value) => { + const block = this.workspace.newBlock('row_block'); + const field = new FieldColour(value); + block.getInput('INPUT').appendField(field, 'COLOUR'); + const jso = Blockly.serialization.blocks.save(block); + assert.deepEqual(jso['fields'], {COLOUR: value}); + }; + }); + + teardown(function () { + this.workspace.dispose(); + }); + + test('Three char', function () { + this.assertValue('#001122'); + }); + + test('Six char', function () { + this.assertValue('#012345'); + }); + }); +}); diff --git a/packages/plugins/field-colour/test/golden/golden.dart b/packages/plugins/field-colour/test/golden/golden.dart new file mode 100644 index 00000000000..80f9f819302 --- /dev/null +++ b/packages/plugins/field-colour/test/golden/golden.dart @@ -0,0 +1,65 @@ +import 'dart:math' as Math; + +String colour_random() { + String hex = '0123456789abcdef'; + var rnd = new Math.Random(); + return '#${hex[rnd.nextInt(16)]}${hex[rnd.nextInt(16)]}' + '${hex[rnd.nextInt(16)]}${hex[rnd.nextInt(16)]}' + '${hex[rnd.nextInt(16)]}${hex[rnd.nextInt(16)]}'; +} + +String colour_rgb(num r, num g, num b) { + num rn = (Math.max(Math.min(r, 100), 0) * 2.55).round(); + String rs = rn.toInt().toRadixString(16); + rs = '0$rs'; + rs = rs.substring(rs.length - 2); + num gn = (Math.max(Math.min(g, 100), 0) * 2.55).round(); + String gs = gn.toInt().toRadixString(16); + gs = '0$gs'; + gs = gs.substring(gs.length - 2); + num bn = (Math.max(Math.min(b, 100), 0) * 2.55).round(); + String bs = bn.toInt().toRadixString(16); + bs = '0$bs'; + bs = bs.substring(bs.length - 2); + return '#$rs$gs$bs'; +} + +String colour_blend(String c1, String c2, num ratio) { + ratio = Math.max(Math.min(ratio, 1), 0); + int r1 = int.parse('0x${c1.substring(1, 3)}'); + int g1 = int.parse('0x${c1.substring(3, 5)}'); + int b1 = int.parse('0x${c1.substring(5, 7)}'); + int r2 = int.parse('0x${c2.substring(1, 3)}'); + int g2 = int.parse('0x${c2.substring(3, 5)}'); + int b2 = int.parse('0x${c2.substring(5, 7)}'); + num rn = (r1 * (1 - ratio) + r2 * ratio).round(); + String rs = rn.toInt().toRadixString(16); + num gn = (g1 * (1 - ratio) + g2 * ratio).round(); + String gs = gn.toInt().toRadixString(16); + num bn = (b1 * (1 - ratio) + b2 * ratio).round(); + String bs = bn.toInt().toRadixString(16); + rs = '0$rs'; + rs = rs.substring(rs.length - 2); + gs = '0$gs'; + gs = gs.substring(gs.length - 2); + bs = '0$bs'; + bs = bs.substring(bs.length - 2); + return '#$rs$gs$bs'; +} + + +main() { + '#ff0000'; + + '#3333ff'; + + colour_random(); + + colour_rgb(100, 50, 0); + + colour_rgb(0, 1, 20); + + colour_blend('#ff0000', '#3333ff', 0.5); + + colour_blend('#000000', '#ffffff', 0.3); +} \ No newline at end of file diff --git a/packages/plugins/field-colour/test/golden/golden.js b/packages/plugins/field-colour/test/golden/golden.js new file mode 100644 index 00000000000..368b4f101ac --- /dev/null +++ b/packages/plugins/field-colour/test/golden/golden.js @@ -0,0 +1,46 @@ +function colourRandom() { + var num = Math.floor(Math.random() * 0x1000000); + return '#' + ('00000' + num.toString(16)).substr(-6); +} + +function colourRgb(r, g, b) { + r = Math.max(Math.min(Number(r), 100), 0) * 2.55; + g = Math.max(Math.min(Number(g), 100), 0) * 2.55; + b = Math.max(Math.min(Number(b), 100), 0) * 2.55; + r = ('0' + (Math.round(r) || 0).toString(16)).slice(-2); + g = ('0' + (Math.round(g) || 0).toString(16)).slice(-2); + b = ('0' + (Math.round(b) || 0).toString(16)).slice(-2); + return '#' + r + g + b; +} + +function colourBlend(c1, c2, ratio) { + ratio = Math.max(Math.min(Number(ratio), 1), 0); + var r1 = parseInt(c1.substring(1, 3), 16); + var g1 = parseInt(c1.substring(3, 5), 16); + var b1 = parseInt(c1.substring(5, 7), 16); + var r2 = parseInt(c2.substring(1, 3), 16); + var g2 = parseInt(c2.substring(3, 5), 16); + var b2 = parseInt(c2.substring(5, 7), 16); + var r = Math.round(r1 * (1 - ratio) + r2 * ratio); + var g = Math.round(g1 * (1 - ratio) + g2 * ratio); + var b = Math.round(b1 * (1 - ratio) + b2 * ratio); + r = ('0' + (r || 0).toString(16)).slice(-2); + g = ('0' + (g || 0).toString(16)).slice(-2); + b = ('0' + (b || 0).toString(16)).slice(-2); + return '#' + r + g + b; +} + + +'#ff0000'; + +'#3333ff'; + +colourRandom(); + +colourRgb(100, 50, 0); + +colourRgb(0, 1, 20); + +colourBlend('#ff0000', '#3333ff', 0.5); + +colourBlend('#000000', '#ffffff', 0.3); diff --git a/packages/plugins/field-colour/test/golden/golden.lua b/packages/plugins/field-colour/test/golden/golden.lua new file mode 100644 index 00000000000..4fdf506d46b --- /dev/null +++ b/packages/plugins/field-colour/test/golden/golden.lua @@ -0,0 +1,35 @@ +function colour_rgb(r, g, b) + r = math.floor(math.min(100, math.max(0, r)) * 2.55 + .5) + g = math.floor(math.min(100, math.max(0, g)) * 2.55 + .5) + b = math.floor(math.min(100, math.max(0, b)) * 2.55 + .5) + return string.format("#%02x%02x%02x", r, g, b) +end + +function colour_blend(colour1, colour2, ratio) + local r1 = tonumber(string.sub(colour1, 2, 3), 16) + local r2 = tonumber(string.sub(colour2, 2, 3), 16) + local g1 = tonumber(string.sub(colour1, 4, 5), 16) + local g2 = tonumber(string.sub(colour2, 4, 5), 16) + local b1 = tonumber(string.sub(colour1, 6, 7), 16) + local b2 = tonumber(string.sub(colour2, 6, 7), 16) + local ratio = math.min(1, math.max(0, ratio)) + local r = math.floor(r1 * (1 - ratio) + r2 * ratio + .5) + local g = math.floor(g1 * (1 - ratio) + g2 * ratio + .5) + local b = math.floor(b1 * (1 - ratio) + b2 * ratio + .5) + return string.format("#%02x%02x%02x", r, g, b) +end + + +local _ = '#ff0000' + +local _ = '#3333ff' + +local _ = string.format("#%06x", math.random(0, 2^24 - 1)) + +local _ = colour_rgb(100, 50, 0) + +local _ = colour_rgb(0, 1, 20) + +local _ = colour_blend('#ff0000', '#3333ff', 0.5) + +local _ = colour_blend('#000000', '#ffffff', 0.3) diff --git a/packages/plugins/field-colour/test/golden/golden.php b/packages/plugins/field-colour/test/golden/golden.php new file mode 100644 index 00000000000..9f2fde50aa2 --- /dev/null +++ b/packages/plugins/field-colour/test/golden/golden.php @@ -0,0 +1,47 @@ +function colour_random() { + return '#' . str_pad(dechex(mt_rand(0, 0xFFFFFF)), 6, '0', STR_PAD_LEFT); +} + +function colour_rgb($r, $g, $b) { + $r = round(max(min($r, 100), 0) * 2.55); + $g = round(max(min($g, 100), 0) * 2.55); + $b = round(max(min($b, 100), 0) * 2.55); + $hex = '#'; + $hex .= str_pad(dechex($r), 2, '0', STR_PAD_LEFT); + $hex .= str_pad(dechex($g), 2, '0', STR_PAD_LEFT); + $hex .= str_pad(dechex($b), 2, '0', STR_PAD_LEFT); + return $hex; +} + +function colour_blend($c1, $c2, $ratio) { + $ratio = max(min($ratio, 1), 0); + $r1 = hexdec(substr($c1, 1, 2)); + $g1 = hexdec(substr($c1, 3, 2)); + $b1 = hexdec(substr($c1, 5, 2)); + $r2 = hexdec(substr($c2, 1, 2)); + $g2 = hexdec(substr($c2, 3, 2)); + $b2 = hexdec(substr($c2, 5, 2)); + $r = round($r1 * (1 - $ratio) + $r2 * $ratio); + $g = round($g1 * (1 - $ratio) + $g2 * $ratio); + $b = round($b1 * (1 - $ratio) + $b2 * $ratio); + $hex = '#'; + $hex .= str_pad(dechex($r), 2, '0', STR_PAD_LEFT); + $hex .= str_pad(dechex($g), 2, '0', STR_PAD_LEFT); + $hex .= str_pad(dechex($b), 2, '0', STR_PAD_LEFT); + return $hex; +} + + +'#ff0000'; + +'#3333ff'; + +colour_random(); + +colour_rgb(100, 50, 0); + +colour_rgb(0, 1, 20); + +colour_blend('#ff0000', '#3333ff', 0.5); + +colour_blend('#000000', '#ffffff', 0.3); diff --git a/packages/plugins/field-colour/test/golden/golden.py b/packages/plugins/field-colour/test/golden/golden.py new file mode 100644 index 00000000000..7320b862473 --- /dev/null +++ b/packages/plugins/field-colour/test/golden/golden.py @@ -0,0 +1,32 @@ +import random + +def colour_rgb(r, g, b): + r = round(min(100, max(0, r)) * 2.55) + g = round(min(100, max(0, g)) * 2.55) + b = round(min(100, max(0, b)) * 2.55) + return '#%02x%02x%02x' % (r, g, b) + +def colour_blend(colour1, colour2, ratio): + r1, r2 = int(colour1[1:3], 16), int(colour2[1:3], 16) + g1, g2 = int(colour1[3:5], 16), int(colour2[3:5], 16) + b1, b2 = int(colour1[5:7], 16), int(colour2[5:7], 16) + ratio = min(1, max(0, ratio)) + r = round(r1 * (1 - ratio) + r2 * ratio) + g = round(g1 * (1 - ratio) + g2 * ratio) + b = round(b1 * (1 - ratio) + b2 * ratio) + return '#%02x%02x%02x' % (r, g, b) + + +'#ff0000' + +'#3333ff' + +'#%06x' % random.randint(0, 2**24 - 1) + +colour_rgb(100, 50, 0) + +colour_rgb(0, 1, 20) + +colour_blend('#ff0000', '#3333ff', 0.5) + +colour_blend('#000000', '#ffffff', 0.3) diff --git a/packages/plugins/field-colour/test/index.html b/packages/plugins/field-colour/test/index.html new file mode 100644 index 00000000000..60fa9f5b29f --- /dev/null +++ b/packages/plugins/field-colour/test/index.html @@ -0,0 +1,18 @@ + + + + + Blockly Field Colour Playground + + + + +
+ + + diff --git a/packages/plugins/field-colour/test/index.ts b/packages/plugins/field-colour/test/index.ts new file mode 100644 index 00000000000..722d0629ea8 --- /dev/null +++ b/packages/plugins/field-colour/test/index.ts @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Colour field test playground. + */ + +import * as Blockly from 'blockly'; +import {javascriptGenerator} from 'blockly/javascript'; +import {dartGenerator} from 'blockly/dart'; +import {phpGenerator} from 'blockly/php'; +import {pythonGenerator} from 'blockly/python'; +import {luaGenerator} from 'blockly/lua'; + +import {createPlayground} from '@blockly/dev-tools'; +import {installAllBlocks as installColourBlocks} from '../src/index'; + +/** + * An array of blocks that are defined only for the purposes of + * manually and visually testing the colour field. + */ +const testBlockDefinitions = [ + { + type: 'test_standard_field_values', + message0: '%1', + args0: [ + { + type: 'field_colour', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_colour`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values', + message0: '%1', + args0: [ + { + type: 'field_colour', + name: 'FIELDNAME', + colour: '#ff4040', + colourOptions: [ + '#ff4040', + '#ff8080', + '#ffc0c0', + '#4040ff', + '#8080ff', + '#c0c0ff', + ], + colourTitles: [ + 'dark pink', + 'pink', + 'light pink', + 'dark blue', + 'blue', + 'light blue', + ], + columns: 3, + alt: { + type: 'field_label', + text: `No field_colour`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_standard_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_colour', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_colour`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_colour', + name: 'FIELDNAME', + colour: '#ff4040', + colourOptions: [ + '#ff4040', + '#ff8080', + '#ffc0c0', + '#4040ff', + '#8080ff', + '#c0c0ff', + ], + colourTitles: [ + 'dark pink', + 'pink', + 'light pink', + 'dark blue', + 'blue', + 'light blue', + ], + columns: 3, + alt: { + type: 'field_label', + text: `No field_colour`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_parent_block', + message0: 'parent %1', + args0: [ + { + type: 'input_value', + name: 'INPUT', + }, + ], + previousStatement: null, + nextStatement: null, + style: 'loop_blocks', + }, +]; + +Blockly.defineBlocksWithJsonArray(testBlockDefinitions); + +/** + * A test toolbox containing the exported blocks and a variety of + * test blocks to exercise the colour field in different contexts + * (on a shadow block, as the only field on a block, etc). + * These are in a simple toolbox, rather than a category toolbox, so that + * they are all instantiated every time the test page is opened. + */ +const jsonToolbox = { + contents: [ + { + kind: 'label', + text: 'Exported blocks', + }, + { + kind: 'block', + type: 'colour_blend', + }, + { + kind: 'block', + type: 'colour_picker', + }, + { + kind: 'block', + type: 'colour_random', + }, + { + kind: 'block', + type: 'colour_rgb', + }, + { + kind: 'label', + text: 'Test blocks: default field values', + }, + { + kind: 'block', + type: 'test_standard_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_standard_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values_and_label', + }, + }, + }, + }, + { + kind: 'label', + text: 'Test blocks: custom field values', + }, + { + kind: 'block', + type: 'test_custom_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_custom_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values_and_label', + }, + }, + }, + }, + ], +}; + +/** + * Create a workspace. + * + * @param blocklyDiv The blockly container div. + * @param options The Blockly options. + * @returns The created workspace. + */ +function createWorkspace( + blocklyDiv: HTMLElement, + options: Blockly.BlocklyOptions, +): Blockly.WorkspaceSvg { + const workspace = Blockly.inject(blocklyDiv, options); + return workspace; +} + +document.addEventListener('DOMContentLoaded', function () { + installColourBlocks({ + javascript: javascriptGenerator, + dart: dartGenerator, + lua: luaGenerator, + python: pythonGenerator, + php: phpGenerator, + }); + + const defaultOptions: Blockly.BlocklyOptions = { + toolbox: jsonToolbox, + }; + const rootElement = document.getElementById('root'); + if (rootElement) { + createPlayground(rootElement, createWorkspace, defaultOptions); + } +}); diff --git a/packages/plugins/field-colour/tsconfig.json b/packages/plugins/field-colour/tsconfig.json new file mode 100644 index 00000000000..46b8e73f3e9 --- /dev/null +++ b/packages/plugins/field-colour/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "es2015", + "moduleResolution": "bundler", + "target": "es6", + "strict": true, + "lib": ["ES2020", "dom"] + }, + // NOTE: `test/**/*` is automatically included in `blockly-scripts start`. + // Only src matters for production builds. + "include": ["src"] +} diff --git a/packages/plugins/field-date/.gitignore b/packages/plugins/field-date/.gitignore new file mode 100644 index 00000000000..800f3a80c34 --- /dev/null +++ b/packages/plugins/field-date/.gitignore @@ -0,0 +1,24 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build +/dist + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/packages/plugins/field-date/CHANGELOG.md b/packages/plugins/field-date/CHANGELOG.md new file mode 100644 index 00000000000..5e3e6a0ffc5 --- /dev/null +++ b/packages/plugins/field-date/CHANGELOG.md @@ -0,0 +1,677 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [13.1.0](https://github.com/RaspberryPiFoundation/blockly-samples/compare/v13.0.0...v13.1.0) (2026-06-30) + +### ⚠ BREAKING CHANGES + +* Bump Blockly dependency to v13 (#2704) + +### Bug Fixes + +* Improve accessibility of `FieldDate` ([#2698](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2698)) + +### Miscellaneous Chores + +* Bump Blockly dependency to v13 ([#2704](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2704)) + + +## [10.0.9](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-date@10.0.8...@blockly/field-date@10.0.9) (2026-04-09) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [10.0.8](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-date@10.0.7...@blockly/field-date@10.0.8) (2026-01-15) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [10.0.7](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-date@10.0.6...@blockly/field-date@10.0.7) (2026-01-05) + + +### Bug Fixes + +* update plugin URLs to RPF ([#2665](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2665)) ([6c34039](https://github.com/RaspberryPiFoundation/blockly-samples/commit/6c3403960b8070eb8e576bd5702bdb53a09ac475)) + + + +## [10.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-date@10.0.5...@blockly/field-date@10.0.6) (2026-01-05) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [10.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-date@10.0.4...@blockly/field-date@10.0.5) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [10.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-date@10.0.3...@blockly/field-date@10.0.4) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [10.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-date@10.0.2...@blockly/field-date@10.0.3) (2025-10-02) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [10.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-date@10.0.1...@blockly/field-date@10.0.2) (2025-07-17) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [10.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-date@10.0.0...@blockly/field-date@10.0.1) (2025-05-22) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [10.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.16...@blockly/field-date@10.0.0) (2025-05-16) + + +### ⚠ BREAKING CHANGES + +* update all plugins to blockly v12 (#2538) + +### Features + +* update all plugins to blockly v12 ([#2538](https://github.com/google/blockly-samples/issues/2538)) ([420ba31](https://github.com/google/blockly-samples/commit/420ba3124b260b2c06e8e1d61be44e870780d95f)) + + + +## [9.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.15...@blockly/field-date@9.0.16) (2025-05-16) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.14...@blockly/field-date@9.0.15) (2025-05-15) + + +### Bug Fixes + +* Wait for an animation frame before showing the date picker ([e3b1054](https://github.com/google/blockly-samples/commit/e3b1054eb0b511aa4ab270725c989369270ee8d6)) + + + + + +## [9.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.13...@blockly/field-date@9.0.14) (2025-02-13) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.12...@blockly/field-date@9.0.13) (2024-12-19) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.11...@blockly/field-date@9.0.12) (2024-12-03) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.10...@blockly/field-date@9.0.11) (2024-11-07) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.9...@blockly/field-date@9.0.10) (2024-09-26) + + +### Bug Fixes + +* **field-*:** Remove unneeded `fieldRegistry.unregister` calls ([#2454](https://github.com/google/blockly-samples/issues/2454)) ([b3ba30e](https://github.com/google/blockly-samples/commit/b3ba30e23dddf0bd98c266659aa229ba6ba685b0)), closes [#2453](https://github.com/google/blockly-samples/issues/2453) + + + + + +## [9.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.8...@blockly/field-date@9.0.9) (2024-08-29) + + +### Bug Fixes + +* predeploy plugins ([#2449](https://github.com/google/blockly-samples/issues/2449)) ([6b36d8b](https://github.com/google/blockly-samples/commit/6b36d8b344a969f79d89bbc7dcee29ae554759f9)) + + + + + +## [9.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.7...@blockly/field-date@9.0.8) (2024-08-22) + + +### Bug Fixes + +* lerna v8 breaking our whole shebangle ([#2446](https://github.com/google/blockly-samples/issues/2446)) ([b8b4c21](https://github.com/google/blockly-samples/commit/b8b4c21d4eaf81f527336ae46f6679ff99ac23c3)) + + + + + +## [9.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.6...@blockly/field-date@9.0.7) (2024-08-08) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.5...@blockly/field-date@9.0.6) (2024-08-01) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.4...@blockly/field-date@9.0.5) (2024-07-25) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.3...@blockly/field-date@9.0.4) (2024-07-11) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.2...@blockly/field-date@9.0.3) (2024-07-04) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.1...@blockly/field-date@9.0.2) (2024-06-06) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [9.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-date@9.0.0...@blockly/field-date@9.0.1) (2024-05-30) + + +### Bug Fixes + +* multiple blockly instances ([#2375](https://github.com/google/blockly-samples/issues/2375)) ([b231e59](https://github.com/google/blockly-samples/commit/b231e598f2f5f5b0abbfd01d981e35572ad50a26)) + + + +## [9.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.17...@blockly/field-date@9.0.0) (2024-05-21) + + +### ⚠ BREAKING CHANGES + +* update all plugins to v11 of blockly + +### Features + +* update all plugins to v11 of blockly ([5fcd3e7](https://github.com/google/blockly-samples/commit/5fcd3e7d53eaadffe9bda9a378b404d34b2f8be2)) +* upgrade ts version, fix field validators ([b2d2191](https://github.com/google/blockly-samples/commit/b2d2191ff11b30347b33f95acf6e58f7ce54d004)) + + +### Bug Fixes + +* Call blocks handle both manual disabling and disabled defs ([#2334](https://github.com/google/blockly-samples/issues/2334)) ([5eade55](https://github.com/google/blockly-samples/commit/5eade55779c4022d14ad4472ff32c93c78199887)) +* remove some uses of generateFieldTextBlocks ([#2357](https://github.com/google/blockly-samples/issues/2357)) ([53b3fa4](https://github.com/google/blockly-samples/commit/53b3fa4604aabb1fc5240d41c8d1d389d4b15301)) + + + +## [8.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.16...@blockly/field-date@8.0.17) (2024-05-16) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.15...@blockly/field-date@8.0.16) (2024-05-09) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.14...@blockly/field-date@8.0.15) (2024-04-25) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.13...@blockly/field-date@8.0.14) (2024-04-04) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.12...@blockly/field-date@8.0.13) (2024-03-28) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.11...@blockly/field-date@8.0.12) (2024-02-16) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.10...@blockly/field-date@8.0.11) (2024-02-08) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.9...@blockly/field-date@8.0.10) (2023-12-14) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.8...@blockly/field-date@8.0.9) (2023-12-07) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.7...@blockly/field-date@8.0.8) (2023-11-09) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.6...@blockly/field-date@8.0.7) (2023-11-02) + + +### Bug Fixes + +* publish types for field-date ([#2049](https://github.com/google/blockly-samples/issues/2049)) ([2086712](https://github.com/google/blockly-samples/commit/20867125156005b346056080057ba95219f73b29)) + + + +## [8.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.5...@blockly/field-date@8.0.6) (2023-10-30) + + +### Bug Fixes + +* **packaging:** Don't advertise non-existent ESM entrypoints ([#2022](https://github.com/google/blockly-samples/issues/2022)) ([d019198](https://github.com/google/blockly-samples/commit/d0191984399b784e2928b8fb4c58257bfa857655)), closes [#1877](https://github.com/google/blockly-samples/issues/1877) +* update tsconfigs to publish types accurately ([df4b084](https://github.com/google/blockly-samples/commit/df4b0844af712f5025a2ec842458b828f3147676)) +* use includes rather than excludes ([40efc25](https://github.com/google/blockly-samples/commit/40efc255329e3ca476ccc247b95a2d05dd77b45e)) + + + +## [8.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.4...@blockly/field-date@8.0.5) (2023-09-21) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.3...@blockly/field-date@8.0.4) (2023-09-14) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.2...@blockly/field-date@8.0.3) (2023-08-17) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.1...@blockly/field-date@8.0.2) (2023-07-24) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-date@8.0.0...@blockly/field-date@8.0.1) (2023-07-20) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [8.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-date@7.0.1...@blockly/field-date@8.0.0) (2023-07-13) + + +### ⚠ BREAKING CHANGES + +* updated `plugins/field-date` to TypeScript (#1705) + +### Features + +* updated `plugins/field-date` to TypeScript ([#1705](https://github.com/google/blockly-samples/issues/1705)) ([e5531ff](https://github.com/google/blockly-samples/commit/e5531fffe188ee361a16fe48ed126b34e51a8d30)) + + + +## [7.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-date@7.0.0...@blockly/field-date@7.0.1) (2023-07-06) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [7.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.1.2...@blockly/field-date@7.0.0) (2023-06-28) + + +### ⚠ BREAKING CHANGES + +* update all plugins to use blockly-v10.0.0 (#1744) + +### Features + +* update all plugins to use blockly-v10.0.0 ([#1744](https://github.com/google/blockly-samples/issues/1744)) ([6cc88cb](https://github.com/google/blockly-samples/commit/6cc88cbef39d4ad664a668d3d46eb29ba7292f9c)) + + + +## [6.1.2](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.1.1...@blockly/field-date@6.1.2) (2023-06-22) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.1.1](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.1.0...@blockly/field-date@6.1.1) (2023-05-18) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.1.0](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.14...@blockly/field-date@6.1.0) (2023-05-11) + + +### Features + +* Add angle, colour, and multiline fields. (Rebase of [#1674](https://github.com/google/blockly-samples/issues/1674)) ([#1693](https://github.com/google/blockly-samples/issues/1693)) ([1b447f4](https://github.com/google/blockly-samples/commit/1b447f41d6293f14e846b8a4d82d289a5637a99a)) + + + +## [6.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.13...@blockly/field-date@6.0.14) (2023-05-04) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.12...@blockly/field-date@6.0.13) (2023-04-27) + + +### Bug Fixes + +* Correct date field test assertions ([#1673](https://github.com/google/blockly-samples/issues/1673)) ([9974e85](https://github.com/google/blockly-samples/commit/9974e85becaa8ad17e35b588b95391c85865dafd)) + + + +## [6.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.11...@blockly/field-date@6.0.12) (2023-03-23) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.10...@blockly/field-date@6.0.11) (2023-03-09) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.9...@blockly/field-date@6.0.10) (2023-03-02) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.8...@blockly/field-date@6.0.9) (2023-02-23) + + +### Bug Fixes + +* remove prepublishOnly scripts ([#1579](https://github.com/google/blockly-samples/issues/1579)) ([27da6cd](https://github.com/google/blockly-samples/commit/27da6cd04c38f6ba417f4e7446bb6218c475448d)) + + + +## [6.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.7...@blockly/field-date@6.0.8) (2023-02-23) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.6...@blockly/field-date@6.0.7) (2023-02-16) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.5...@blockly/field-date@6.0.6) (2023-02-09) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.4...@blockly/field-date@6.0.5) (2023-01-26) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.3...@blockly/field-date@6.0.4) (2023-01-05) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.2...@blockly/field-date@6.0.3) (2022-12-15) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.1...@blockly/field-date@6.0.2) (2022-10-27) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-date@6.0.0...@blockly/field-date@6.0.1) (2022-10-13) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## [6.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-date@5.0.22...@blockly/field-date@6.0.0) (2022-10-05) + + +### ⚠ BREAKING CHANGES + +* update peer and devDependencies of all plugins to require Blockly v9 (#1314) +* update field-date to es6 class (#1295) + +### Bug Fixes + +* references to deprecated functions in v9 ([#1313](https://github.com/google/blockly-samples/issues/1313)) ([cb2e679](https://github.com/google/blockly-samples/commit/cb2e67987e0b62a77c26adc660cc6ade1ba67954)) +* update field-date to es6 class ([#1295](https://github.com/google/blockly-samples/issues/1295)) ([9b70b03](https://github.com/google/blockly-samples/commit/9b70b0300c625c0cddc1eea241a3d2c2200344f8)) + + +### Miscellaneous Chores + +* update peer and devDependencies of all plugins to require Blockly v9 ([#1314](https://github.com/google/blockly-samples/issues/1314)) ([03d4912](https://github.com/google/blockly-samples/commit/03d4912c42c8de0f30493037ccc28dddaea0f266)) + + + +## 5.0.22 (2022-08-11) + + +### Bug Fixes + +* Remove the deprecated block-extension-tooltip ([#1215](https://github.com/google/blockly-samples/issues/1215)) ([a044478](https://github.com/google/blockly-samples/commit/a044478c86a73e3065bc866e427f175cbec6fc13)) + + + + + +## 5.0.21 (2022-08-04) + + +### Bug Fixes + +* fix the name of the package of suggested-blocks ([#1214](https://github.com/google/blockly-samples/issues/1214)) ([35aa8ec](https://github.com/google/blockly-samples/commit/35aa8ec73a60a4eb5b1e80cb2fc71dcd83d05e27)) + + + + + +## 5.0.19 (2022-07-21) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## 5.0.18 (2022-07-06) + + +### Bug Fixes + +* Update blockly-react to use functional components instead of class components. ([#1178](https://github.com/google/blockly-samples/issues/1178)) ([fa21187](https://github.com/google/blockly-samples/commit/fa21187cdbe4ec3a5c69f185540dd68a98eb69d7)) + + + + + +## 5.0.17 (2022-06-27) + + +### Bug Fixes + +* Update package import for keyboard navigation demo ([#1170](https://github.com/google/blockly-samples/issues/1170)) ([69c1725](https://github.com/google/blockly-samples/commit/69c1725b775279fcc397dc178935208d5f42b08c)) + + + + + +## 5.0.16 (2022-06-21) + +**Note:** Version bump only for package @blockly/field-date + + + + + +## 5.0.15 (2022-06-08) + + +### Bug Fixes + +* package versions to support patch releases ([#1150](https://github.com/google/blockly-samples/issues/1150)) ([e1ae378](https://github.com/google/blockly-samples/commit/e1ae378d779531621c3d948566257d069002963f)) + + + + + +## 5.0.14 (2022-06-02) + + +### Bug Fixes + +* add git identity ([#1156](https://github.com/google/blockly-samples/issues/1156)) ([8d80924](https://github.com/google/blockly-samples/commit/8d809243b277375beb2ce75d4e157b5e17f78193)) diff --git a/packages/plugins/field-date/README.md b/packages/plugins/field-date/README.md new file mode 100644 index 00000000000..8b44897f616 --- /dev/null +++ b/packages/plugins/field-date/README.md @@ -0,0 +1,59 @@ +# @blockly/field-date [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +A [Blockly](https://www.npmjs.com/package/blockly) plugin that uses a browser default "date" input. + +## Installation + +### Yarn + +``` +yarn add @blockly/field-date +``` + +### npm + +``` +npm install @blockly/field-date --save +``` + +## Usage + +### JavaScript + +```js +import * as Blockly from 'blockly'; +import {FieldDate} from '@blockly/field-date'; + +Blockly.Blocks['test_fields_date'] = { + init: function () { + this.appendDummyInput() + .appendField('date: ') + .appendField(new FieldDate('2020-02-20'), 'FIELDNAME'); + }, +}; +``` + +### JSON + +```js +import * as Blockly from 'blockly'; +import '@blockly/field-date'; + +Blockly.defineBlocksWithJsonArray([ + { + type: 'test_fields_date', + message0: 'date: %1', + args0: [ + { + type: 'field_date', + name: 'FIELDNAME', + date: '2020-02-20', + }, + ], + }, +]); +``` + +## License + +Apache 2.0 diff --git a/packages/plugins/field-date/package.json b/packages/plugins/field-date/package.json new file mode 100644 index 00000000000..881df11fcbb --- /dev/null +++ b/packages/plugins/field-date/package.json @@ -0,0 +1,57 @@ +{ + "name": "@blockly/field-date", + "version": "13.1.0", + "description": "A Blockly date picker field that uses the browser's date picker.", + "scripts": { + "audit:fix": "blockly-scripts auditFix", + "build": "blockly-scripts build", + "clean": "blockly-scripts clean", + "lint": "eslint .", + "predeploy": "blockly-scripts predeploy", + "start": "blockly-scripts start", + "test": "blockly-scripts test" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.js", + "author": "Blockly Team", + "keywords": [ + "blockly", + "blockly-plugin", + "field-date", + "field", + "date", + "datepicker" + ], + "homepage": "https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/field-date#readme", + "bugs": { + "url": "https://github.com/RaspberryPiFoundation/blockly/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaspberryPiFoundation/blockly.git", + "directory": "packages/plugins/field-date" + }, + "license": "Apache-2.0", + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src" + ], + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/field-date/src/field_date.ts b/packages/plugins/field-date/src/field_date.ts new file mode 100644 index 00000000000..cf9601b30f6 --- /dev/null +++ b/packages/plugins/field-date/src/field_date.ts @@ -0,0 +1,286 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Plugin overview. + */ +import * as Blockly from 'blockly/core'; +import {getLocaleDateString} from './utils'; + +/** + * Class for a date input field. + */ +export class FieldDate extends Blockly.FieldTextInput { + /** + * Serializable fields are saved by the XML renderer, non-serializable fields + * are not. Editable fields should also be serializable. + */ + SERIALIZABLE = true; + + /** + * Class for a date input field. Derived from the Closure library date + * picker. + * + * @param value The initial value of the field. Should be in + * 'YYYY-MM-DD' format. Defaults to the current date. + * @param validator A function that is called to validate + * changes to the field's value. Takes in a date string & returns a + * validated date string ('YYYY-MM-DD' format), or null to abort the + * change. + * @param config A map of options used to configure the field. + */ + constructor( + value?: string, + validator?: FieldDateValidator, + config?: FieldDateConfig, + ) { + super(value, validator, config); + } + + /** + * Constructs a FieldDate from a JSON arg object. + * + * @param options A JSON object with options (date). + * @returns The new field instance. + * @package + * @nocollapse + */ + static fromJson(options: FieldDateFromJsonConfig): FieldDate { + const {date, ...fieldDateConfig} = options; + // `this` might be a subclass of FieldDate if that class doesn't + // override the static fromJson method. + return new this(date, undefined, fieldDateConfig); + } + + /* eslint-disable @typescript-eslint/naming-convention */ + /** + * Ensures that the input value is a valid date. Additionally, if the date + * string provided includes a time, the time will be removed and the date for + * relative to the user's timezone will be used. + * + * @param newValue The input value. Ex: '2023-04-28' + * @returns A valid date string, or null if invalid. + * @override + */ + protected doClassValidation_(newValue?: string): string | null { + if (!newValue) return null; + + const newDate = typeof newValue === 'string' ? new Date(newValue) : null; + if (!newDate || isNaN(newDate.getTime())) return null; + + // NOTE: 'newValue' should be a valid date format here. + if (isISOFormat(newValue)) return newValue; + + // Assume the time needs to be corrected. + return toLocalISOString(newDate); + } + + /** + * Get the text to display on the block when the input hasn't spawned in. + * + * @returns The text to display on the block. + * @override + */ + protected getText_(): string | null { + const value = this.getValue(); + if (!value) return null; + // NOTE: There may be discrepancies between the text and the input based on + // browser. For example, 'en-US' would display the text '2/14/2020', then + // clicking in Safari on iOS would display 'Feb 14, 2020'. + return getLocaleDateString(value); + } + + /** + * Returns a description of the type of this field for screenreaders. + */ + override getAriaTypeName() { + return Blockly.Msg['ARIA_TYPE_FIELD_DATE']; + } + + /** + * Returns a description of the current date for use by screenreaders. + */ + override getAriaValue() { + const stringValue = this.getValue(); + if (!stringValue) return super.getAriaValue(); + + const date = new Date(stringValue); + // Use a localized long-form description of the date, e.g. January XX, 20XX, + // rather than a short-form/ISO version of the date which may be read out + // with slashes or the like. + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); + } + + /** + * Renders the field. If the picker is shown make sure it has the current + * date selected. + */ + protected render_() { + super.render_(); + } + + /** + * Shows the inline free-text editor on top of the text along with the date + * editor. + * + * @param e Optional mouse event that triggered the field to + * open, or undefined if triggered programmatically. + * @override + */ + protected showEditor_(e?: Event) { + // Pass in `true` for `quietInput` to disable modal inputs for the date + // block without setting `this.sourceBlock_.workspace.options.modalInputs`, + // which would impact the entire workspace. + super.showEditor_(e, true); + + // Even though `quietInput` was set true, focus on the element. + this.htmlInput_?.focus({ + preventScroll: true, + }); + this.htmlInput_?.select(); + this.showDropdown(); + } + + /** + * Updates the size of the field based on the text. + * + * @param margin margin to use when positioning the text element. + * @override + */ + protected updateSize_(margin?: number) { + // Add margin so that the date input's datepicker icon doesn't clip with + // the text when sized for the date. + super.updateSize_((margin ?? 0) + 20); + } + + /** + * Shows the datepicker. + */ + private showDropdown(): void { + if (!this.htmlInput_) return; + Blockly.utils.dom.addClass(this.htmlInput_, 'blocklyDateInput'); + + // Delay showing the picker until the editor has a chance to position + window.requestAnimationFrame(() => { + // NOTE: HTMLInputElement.showPicker() is not available in earlier + // TypeScript versions (like 4.7.4), so casting to `any` to be compatible + // with dev scripts. Additionally, it's not available for date inputs for + // Safari. For browser compatibility of showPicker, see: + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/showPicker + /* eslint-disable @typescript-eslint/no-explicit-any */ + (this.htmlInput_ as any).showPicker(); + /* eslint-enable @typescript-eslint/no-explicit-any */ + }); + } + + /** + * Create the html input and set it to type date. + * + * @returns The newly created date input editor. + */ + protected widgetCreate_(): HTMLInputElement { + // NOTE: field_input should return HTMLInputElement for this. + const htmlInput = super.widgetCreate_() as HTMLInputElement; + htmlInput.type = 'date'; + + return htmlInput; + } + /* eslint-enable @typescript-eslint/naming-convention */ +} + +/** + * NOTE: There are a few minor ways to tweak the datepicker CSS, though they're + * not consistent across browsers. + * + * @see{@link https://developer.mozilla.org/en-US/docs/Learn/Forms/Property_compatibility_table_for_form_controls#date_pickers} + * + * Below are a few ways this can be tweaked on *some* browsers: + * Blockly.Css.register(` + * ::-webkit-datetime-edit { } + * ::-webkit-datetime-edit-fields-wrapper { } + * ::-webkit-datetime-edit-text { } + * ::-webkit-datetime-edit-month-field { } + * ::-webkit-datetime-edit-day-field { } + * ::-webkit-datetime-edit-year-field { } + * ::-webkit-inner-spin-button { } + * ::-webkit-calendar-picker-indicator { } + * `); + */ +if (Blockly.utils.userAgent.MAC) { + // NOTE: By default, 4 px padding total are added within the User Agent + // Shadow Content on Safari on MAC. Remove the padding so the inner input + // matches the outer input's height and, by extension, the height of the text + // node. + Blockly.Css.register(` +input.blocklyDateInput::-webkit-datetime-edit, +input.blocklyDateInput::-webkit-datetime-edit-month-field, +input.blocklyDateInput::-webkit-datetime-edit-day-field, +input.blocklyDateInput::-webkit-datetime-edit-year-field { + padding: 0; +} +`); +} + +Blockly.fieldRegistry.register('field_date', FieldDate); + +/** + * A config object for defining a field date. + */ +export interface FieldDateConfig extends Blockly.FieldTextInputConfig { + // NOTE: spellcheck is defined for FieldInput though irrelevant for FieldDate. + spellcheck?: never; +} + +/** + * Options used to define a field date from JSON. + */ +export interface FieldDateFromJsonConfig extends FieldDateConfig { + date?: string; +} + +export type FieldDateValidator = Blockly.FieldTextInputValidator; + +/** + * Validate a string value to see if it matches the format. + * + * @param value The value to validate the format for. + * @returns true if the value is in 'yyyy-mm-dd' format. + * @example + * isISOFormat('2000-02-20T00:00:00Z') === false + * isISOFormat('2000-02-20') === true + */ +export function isISOFormat(value: string): boolean { + const valueMatch = value.match(/\d\d\d\d-\d\d-\d\d/); + // If it matches ####-##-## and is the same as its input string, + // then assume this is the right format + return valueMatch !== null && valueMatch[0] === valueMatch.input; +} + +/** + * Convert the date to ISO format for the current timezone. + * + * @param date The date to convert to an ISO string. + * @returns The string in 'yyyy-mm-dd' format, though for the current timezone. + * Ex: new Date('2000-02-20') + */ +export function toLocalISOString(date: Date) { + // NOTE: If the date is Feb 20, 2000 at 23:00 for GMT-6, it would be + // '2000-02-21' at GMT+0, which is what `date.toISOString()` would return. + // For a user whose timezone is GMT-6, this should return '2000-02-20'. + // For a user whose timezone is GMT-5, that date should return '2000-02-21'. + return date + .toLocaleDateString('en-US') + .replace(/(\d+)\/(\d+)\/(\d+)/, '$3-$1-$2') + .replace(/-(\d)(?!\d)/g, '-0$1'); +} + +// NOTE: Set default here instead of in class so it's available at Field. +FieldDate.prototype.DEFAULT_VALUE = toLocalISOString(new Date()); diff --git a/packages/plugins/field-date/src/index.ts b/packages/plugins/field-date/src/index.ts new file mode 100644 index 00000000000..480c3fe80de --- /dev/null +++ b/packages/plugins/field-date/src/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './field_date'; diff --git a/packages/plugins/field-date/src/utils.ts b/packages/plugins/field-date/src/utils.ts new file mode 100644 index 00000000000..7059216d215 --- /dev/null +++ b/packages/plugins/field-date/src/utils.ts @@ -0,0 +1,20 @@ +/** + * Get the string formatted locally to the user. + * + * @param dateString A string in the format 'yyyy-mm-dd' + * @returns the locale date string for the date. + */ +export function getLocaleDateString(dateString: string): string { + // NOTE: `date.toLocaleDateString()` will be the day before for western dates + // due to an unspecified time & timezone assuming midnight at GMT+0. + const date = new Date(dateString); + + // NOTE: This format varies per region. + // Ex: "5/12/2023", "12/05/2023", "12.5.2023", "2023/5/12", "१२/५/२०२३" + const language = navigator.language ?? 'en-US'; + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options + return new Intl.DateTimeFormat(language, { + // Print the date for GMT+0 since the date object assumes midnight at GMT+0. + timeZone: 'UTC', + }).format(date); +} diff --git a/packages/plugins/field-date/test/field_date_test.mocha.js b/packages/plugins/field-date/test/field_date_test.mocha.js new file mode 100644 index 00000000000..f818c08ce80 --- /dev/null +++ b/packages/plugins/field-date/test/field_date_test.mocha.js @@ -0,0 +1,267 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from 'chai'; +import {testHelpers} from '@blockly/dev-tools'; +import {FieldDate} from '../src/index'; +import {getLocaleDateString} from '../src/utils'; + +const { + assertFieldValue, + FieldCreationTestCase, + FieldValueTestCase, + runConstructorSuiteTests, + runFromJsonSuiteTests, + runSetValueTests, +} = testHelpers; + +// Add polyfill for global variables needed. +if (!global.navigator) { + global.navigator = { + language: 'en-US', + }; +} + +suite('FieldDate', function () { + /** + * Configuration for field tests with invalid values. + * @type {Array} + */ + const invalidValueTestCases = [ + {title: 'Undefined', value: undefined}, + {title: 'Null', value: null}, + {title: 'NaN', value: NaN}, + {title: 'Non-Parsable String', value: 'bad'}, + {title: 'Invalid Date - Month(2020-13-20)', value: '2020-13-20'}, + {title: 'Invalid Date - Day(2020-02-32)', value: '2020-02-32'}, + ]; + /** + * Configuration for field tests with valid values. + * @type {Array} + */ + const validValueTestCases = [ + { + title: 'String', + value: '3030-03-30', + expectedValue: '3030-03-30', + expectedText: '3/30/3030', + }, + ]; + const addArgsAndJson = function (testCase) { + testCase.args = [testCase.value]; + testCase.json = {date: testCase.value}; + }; + invalidValueTestCases.forEach(addArgsAndJson); + validValueTestCases.forEach(addArgsAndJson); + // Construct ISO string using current timezone. + // Cannot use toISOString() because it returns in UTC. + const defaultFieldValue = new Date() + .toLocaleDateString('en-US') + .replace(/(\d+)\/(\d+)\/(\d+)/, '$3-$1-$2') + .replace(/-(\d)(?!\d)/g, '-0$1'); + // NOTE: The actual text depends on system settings of the one running it. + const defaultFieldText = new Date().toLocaleDateString(); + const assertFieldDefault = function (field) { + assertFieldValue(field, defaultFieldValue, defaultFieldText); + }; + + /** + * Asserts that the field properties are correct based on the test case. + * @param {FieldDate} field The field to check. + * @param {FieldValueTestCase} testCase The test case. + */ + const validTestCaseAssertField = function (field, testCase) { + assertFieldValue(field, testCase.expectedValue, testCase.expectedText); + }; + + runConstructorSuiteTests( + FieldDate, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + runFromJsonSuiteTests( + FieldDate, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + suite('setValue', function () { + suite('Empty -> New Value', function () { + setup(function () { + this.field = new FieldDate(); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + defaultFieldValue, + defaultFieldText, + ); + }); + suite('Value -> New Value', function () { + const initialValue = '2020-02-20'; + const initialText = getLocaleDateString(initialValue); + setup(function () { + this.field = new FieldDate(initialValue); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + initialValue, + initialText, + ); + }); + }); + + suite('Validators', function () { + setup(function () { + this.field = new FieldDate('2020-02-20'); + }); + teardown(function () { + this.field.setValidator(null); + }); + suite('when validator returns null', function () { + setup(function () { + this.field.setValidator(() => { + return null; + }); + }); + test('should not set the new value', function () { + this.field.setValue('3030-03-30'); + assertFieldValue(this.field, '2020-02-20', '2/20/2020'); + }); + }); + suite('when validator sets day to 20s', function () { + setup(function () { + this.field.setValidator(function (newValue) { + return newValue.substr(0, 8) + '2' + newValue.substr(9, 1); + }); + }); + test('should set the value to a "20s" date', function () { + this.field.setValue('3030-03-30'); + assertFieldValue(this.field, '3030-03-20', '3/20/3030'); + }); + }); + suite('when validator returns undefined', function () { + setup(function () { + this.field.setValidator(() => {}); + }); + test('should set the value without changing it', function () { + this.field.setValue('3030-03-30'); + assertFieldValue(this.field, '3030-03-30', '3/30/3030'); + }); + }); + }); + + suite('Time Zones', function () { + // https://nodejs.org/api/cli.html#tz + const INITIAL_TZ = process.env.TZ; + const TZ_STRINGS = { + WESTERN: 'America/New_York', + UNIVERSAL: 'Etc/Universal', + EASTERN: 'Europe/Paris', + }; + const TZ_OFFSET = { + WESTERN: '-05:00', + UNIVERSAL: '+00:00', + EASTERN: '+01:00', + }; + + suite('Western', () => { + setup(() => { + process.env.TZ = TZ_STRINGS.WESTERN; + }); + + test('should return the same date when provided a date string', () => { + const dateString = '2000-01-23'; + const fieldDate = new FieldDate(dateString); + assert.equal(fieldDate.getValue(), dateString); + }); + + test('should return the same date for 23:00 western datetime', () => { + const dateString = '2000-01-23'; + const dateTimeString = dateString + 'T23:00:00' + TZ_OFFSET.WESTERN; + const fieldDate = new FieldDate(dateTimeString); + assert.equal(fieldDate.getValue(), dateString); + }); + + test('should return the day before for 00:00 universal datetime', () => { + const dateString = '2000-01-23'; + const dateTimeString = dateString + 'T00:00:00' + TZ_OFFSET.UNIVERSAL; + const fieldDate = new FieldDate(dateTimeString); + assert.equal(fieldDate.getValue(), '2000-01-22'); + }); + + teardown(() => { + process.env.TSZ = INITIAL_TZ; + }); + }); + + suite('Universal', () => { + setup(() => { + process.env.TZ = TZ_STRINGS.UNIVERSAL; + }); + + test('should return the same date when provided a date string', () => { + const dateString = '2000-01-23'; + const fieldDate = new FieldDate(dateString); + assert.equal(fieldDate.getValue(), dateString); + }); + + test('should return the day after for 23:00 western datetime', () => { + const dateString = '2000-01-23'; + const dateTimeString = dateString + 'T23:00:00' + TZ_OFFSET.WESTERN; + const fieldDate = new FieldDate(dateTimeString); + assert.equal(fieldDate.getValue(), '2000-01-24'); + }); + + test('should return the same date for 00:00 universal datetime', () => { + const dateString = '2000-01-23'; + const dateTimeString = dateString + 'T00:00:00' + TZ_OFFSET.UNIVERSAL; + const fieldDate = new FieldDate(dateTimeString); + assert.equal(fieldDate.getValue(), dateString); + }); + + teardown(() => { + process.env.TSZ = INITIAL_TZ; + }); + }); + + suite('Eastern', () => { + setup(() => { + process.env.TZ = TZ_STRINGS.EASTERN; + }); + + test('should return the same date when provided a date string', () => { + const dateString = '2000-01-23'; + const fieldDate = new FieldDate(dateString); + assert.equal(fieldDate.getValue(), dateString); + }); + + test('should return the day after for 23:00 universal datetime', () => { + const dateString = '2000-01-23'; + const dateTimeString = dateString + 'T23:00:00' + TZ_OFFSET.UNIVERSAL; + const fieldDate = new FieldDate(dateTimeString); + assert.equal(fieldDate.getValue(), '2000-01-24'); + }); + + test('should return the same date for 00:00 eastern datetime', () => { + const dateString = '2000-01-23'; + const dateTimeString = dateString + 'T00:00:00' + TZ_OFFSET.EASTERN; + const fieldDate = new FieldDate(dateTimeString); + assert.equal(fieldDate.getValue(), dateString); + }); + + teardown(() => { + process.env.TSZ = INITIAL_TZ; + }); + }); + }); +}); diff --git a/packages/plugins/field-date/test/index.html b/packages/plugins/field-date/test/index.html new file mode 100644 index 00000000000..1f568ca9998 --- /dev/null +++ b/packages/plugins/field-date/test/index.html @@ -0,0 +1,18 @@ + + + + + Blockly Field Date Playground + + + + +
+ + + diff --git a/packages/plugins/field-date/test/index.ts b/packages/plugins/field-date/test/index.ts new file mode 100644 index 00000000000..050763dab19 --- /dev/null +++ b/packages/plugins/field-date/test/index.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Plugin test. + */ + +import * as Blockly from 'blockly'; +import {createPlayground} from '@blockly/dev-tools'; +import '../src/index'; + +/** + * An array of blocks that are defined only for the purposes of + * manually and visually testing the date field. + */ +const testBlockDefinitions = [ + { + type: 'test_standard_field_values', + message0: '%1', + args0: [ + { + type: 'field_date', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_date`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values', + message0: '%1', + args0: [ + { + type: 'field_date', + name: 'FIELDNAME', + date: '2020-02-20', + alt: { + type: 'field_label', + text: `No field_date`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_standard_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_date', + name: 'FIELDNAME', + alt: { + type: 'field_label', + text: `No field_date`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_date', + name: 'FIELDNAME', + date: '2020-02-20', + alt: { + type: 'field_label', + text: `No field_date`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_parent_block', + message0: 'parent %1', + args0: [ + { + type: 'input_value', + name: 'INPUT', + }, + ], + previousStatement: null, + nextStatement: null, + style: 'loop_blocks', + }, + { + type: 'test_custom_field_values_and_tooltip', + message0: 'block %1', + args0: [ + { + type: 'field_date', + name: 'FIELDNAME', + date: '2021-03-13', + tooltip: 'This date block has a tooltip!', + alt: { + type: 'field_label', + text: `No field_date`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, +]; + +Blockly.defineBlocksWithJsonArray(testBlockDefinitions); + +/** + * A test toolbox containing the exported blocks and a variety of + * test blocks to exercise the date field in different contexts + * (on a shadow block, as the only field on a block, etc). + * These are in a simple toolbox, rather than a category toolbox, so that + * they are all instantiated every time the test page is opened. + */ +const jsonToolbox = { + contents: [ + { + kind: 'label', + text: 'Test blocks: default field values', + }, + { + kind: 'block', + type: 'test_standard_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_standard_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values_and_label', + }, + }, + }, + }, + { + kind: 'label', + text: 'Test blocks: custom field values', + }, + { + kind: 'block', + type: 'test_custom_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_custom_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values_and_label', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_custom_field_values_and_tooltip', + }, + ], +}; + +/** + * Create a workspace. + * + * @param blocklyDiv The blockly container div. + * @param options The Blockly options. + * @returns The created workspace. + */ +function createWorkspace( + blocklyDiv: HTMLElement, + options: Blockly.BlocklyOptions, +): Blockly.WorkspaceSvg { + const workspace = Blockly.inject(blocklyDiv, options); + return workspace; +} + +document.addEventListener('DOMContentLoaded', function () { + const defaultOptions: Blockly.BlocklyOptions = { + toolbox: jsonToolbox, + }; + const rootElement = document.getElementById('root'); + if (rootElement) { + // NOTE: Will need to update @blockly/dev-tools package JSON to keep it's + // version of Blockly in sync with this package's. Both call for ^9.0.0, + // though the package-locks result in each installing different versions. + createPlayground(rootElement, createWorkspace, defaultOptions); + } +}); diff --git a/packages/plugins/field-date/tsconfig.json b/packages/plugins/field-date/tsconfig.json new file mode 100644 index 00000000000..99858c525f1 --- /dev/null +++ b/packages/plugins/field-date/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "es2015", + "moduleResolution": "bundler", + "target": "es6", + "strict": true + }, + // NOTE: `test/**/*` is automatically included in `blockly-scripts start`. + // Only src matters for production builds. + "include": ["src"] +} diff --git a/packages/plugins/field-dependent-dropdown/CHANGELOG.md b/packages/plugins/field-dependent-dropdown/CHANGELOG.md new file mode 100644 index 00000000000..568ad4ffd0a --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/CHANGELOG.md @@ -0,0 +1,500 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [13.1.0](https://github.com/RaspberryPiFoundation/blockly-samples/compare/v13.0.0...v13.1.0) (2026-06-30) + +### ⚠ BREAKING CHANGES + +* make field-multilineinput keyboard navigable and accessible (#2729) +* Bump Blockly dependency to v13 (#2704) + +### Features + +* make field-multilineinput keyboard navigable and accessible ([#2729](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2729)) + +### Miscellaneous Chores + +* Bump Blockly dependency to v13 ([#2704](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2704)) + + +## [5.0.10](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.9...@blockly/field-dependent-dropdown@5.0.10) (2026-04-09) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.9](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.8...@blockly/field-dependent-dropdown@5.0.9) (2026-01-15) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.8](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.7...@blockly/field-dependent-dropdown@5.0.8) (2026-01-05) + + +### Bug Fixes + +* update plugin URLs to RPF ([#2665](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2665)) ([6c34039](https://github.com/RaspberryPiFoundation/blockly-samples/commit/6c3403960b8070eb8e576bd5702bdb53a09ac475)) + + + +## [5.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.6...@blockly/field-dependent-dropdown@5.0.7) (2026-01-05) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.5...@blockly/field-dependent-dropdown@5.0.6) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.4...@blockly/field-dependent-dropdown@5.0.5) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.3...@blockly/field-dependent-dropdown@5.0.4) (2025-10-30) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.2...@blockly/field-dependent-dropdown@5.0.3) (2025-10-02) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.1...@blockly/field-dependent-dropdown@5.0.2) (2025-07-17) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@5.0.0...@blockly/field-dependent-dropdown@5.0.1) (2025-05-22) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [5.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.16...@blockly/field-dependent-dropdown@5.0.0) (2025-05-16) + + +### ⚠ BREAKING CHANGES + +* update all plugins to blockly v12 (#2538) + +### Features + +* update all plugins to blockly v12 ([#2538](https://github.com/google/blockly-samples/issues/2538)) ([420ba31](https://github.com/google/blockly-samples/commit/420ba3124b260b2c06e8e1d61be44e870780d95f)) + + + +## [4.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.15...@blockly/field-dependent-dropdown@4.0.16) (2025-05-16) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.14...@blockly/field-dependent-dropdown@4.0.15) (2025-05-15) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.13...@blockly/field-dependent-dropdown@4.0.14) (2025-02-13) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.12...@blockly/field-dependent-dropdown@4.0.13) (2024-12-19) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.11...@blockly/field-dependent-dropdown@4.0.12) (2024-12-03) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.10...@blockly/field-dependent-dropdown@4.0.11) (2024-11-07) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.9...@blockly/field-dependent-dropdown@4.0.10) (2024-09-26) + + +### Bug Fixes + +* **field-*:** Remove unneeded `fieldRegistry.unregister` calls ([#2454](https://github.com/google/blockly-samples/issues/2454)) ([b3ba30e](https://github.com/google/blockly-samples/commit/b3ba30e23dddf0bd98c266659aa229ba6ba685b0)), closes [#2453](https://github.com/google/blockly-samples/issues/2453) + + + + + +## [4.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.8...@blockly/field-dependent-dropdown@4.0.9) (2024-08-29) + + +### Bug Fixes + +* predeploy plugins ([#2449](https://github.com/google/blockly-samples/issues/2449)) ([6b36d8b](https://github.com/google/blockly-samples/commit/6b36d8b344a969f79d89bbc7dcee29ae554759f9)) + + + + + +## [4.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.7...@blockly/field-dependent-dropdown@4.0.8) (2024-08-22) + + +### Bug Fixes + +* lerna v8 breaking our whole shebangle ([#2446](https://github.com/google/blockly-samples/issues/2446)) ([b8b4c21](https://github.com/google/blockly-samples/commit/b8b4c21d4eaf81f527336ae46f6679ff99ac23c3)) + + + + + +## [4.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.6...@blockly/field-dependent-dropdown@4.0.7) (2024-08-08) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.5...@blockly/field-dependent-dropdown@4.0.6) (2024-08-01) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.4...@blockly/field-dependent-dropdown@4.0.5) (2024-07-25) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.3...@blockly/field-dependent-dropdown@4.0.4) (2024-07-11) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.2...@blockly/field-dependent-dropdown@4.0.3) (2024-07-04) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.1...@blockly/field-dependent-dropdown@4.0.2) (2024-06-06) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [4.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@4.0.0...@blockly/field-dependent-dropdown@4.0.1) (2024-05-30) + + +### Bug Fixes + +* multiple blockly instances ([#2375](https://github.com/google/blockly-samples/issues/2375)) ([b231e59](https://github.com/google/blockly-samples/commit/b231e598f2f5f5b0abbfd01d981e35572ad50a26)) + + + +## [4.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.17...@blockly/field-dependent-dropdown@4.0.0) (2024-05-21) + + +### ⚠ BREAKING CHANGES + +* update all plugins to v11 of blockly +* **dev-scripts:** This PR removes the support that was added + +### Features + +* update all plugins to v11 of blockly ([5fcd3e7](https://github.com/google/blockly-samples/commit/5fcd3e7d53eaadffe9bda9a378b404d34b2f8be2)) +* upgrade ts version, fix field validators ([b2d2191](https://github.com/google/blockly-samples/commit/b2d2191ff11b30347b33f95acf6e58f7ce54d004)) + + +### Bug Fixes + +* Call blocks handle both manual disabling and disabled defs ([#2334](https://github.com/google/blockly-samples/issues/2334)) ([5eade55](https://github.com/google/blockly-samples/commit/5eade55779c4022d14ad4472ff32c93c78199887)) +* **dev-scripts:** Fixes, refactoring and simplification of `webpack.config.js` and `'blockly'` imports ([#2229](https://github.com/google/blockly-samples/issues/2229)) ([f5ffdb9](https://github.com/google/blockly-samples/commit/f5ffdb961e3b60ddb164087f4bddc4e6215906b7)), closes [#335](https://github.com/google/blockly-samples/issues/335) [#335](https://github.com/google/blockly-samples/issues/335) [#1630](https://github.com/google/blockly-samples/issues/1630) [#335](https://github.com/google/blockly-samples/issues/335) [#226](https://github.com/google/blockly-samples/issues/226) [google/blockly#7822](https://github.com/google/blockly/issues/7822) [google/blockly#7822](https://github.com/google/blockly/issues/7822) [/github.com/google/blockly-samples/pull/2229#issuecomment-1979123919](https://github.com/google//github.com/google/blockly-samples/pull/2229/issues/issuecomment-1979123919) + + + +## [3.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.16...@blockly/field-dependent-dropdown@3.0.17) (2024-05-16) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.15...@blockly/field-dependent-dropdown@3.0.16) (2024-05-09) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.14...@blockly/field-dependent-dropdown@3.0.15) (2024-04-25) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.13...@blockly/field-dependent-dropdown@3.0.14) (2024-04-04) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.12...@blockly/field-dependent-dropdown@3.0.13) (2024-03-28) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.11...@blockly/field-dependent-dropdown@3.0.12) (2024-02-08) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.10...@blockly/field-dependent-dropdown@3.0.11) (2023-12-14) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.9...@blockly/field-dependent-dropdown@3.0.10) (2023-12-07) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.8...@blockly/field-dependent-dropdown@3.0.9) (2023-11-16) + + +### Bug Fixes + +* resolved 15 ESLint warnings ([#2065](https://github.com/google/blockly-samples/issues/2065)) ([2436337](https://github.com/google/blockly-samples/commit/243633746542bb1518fe2893c0421a5a6f79fd32)) + + + +## [3.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.7...@blockly/field-dependent-dropdown@3.0.8) (2023-11-09) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.6...@blockly/field-dependent-dropdown@3.0.7) (2023-11-02) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.5...@blockly/field-dependent-dropdown@3.0.6) (2023-10-30) + + +### Bug Fixes + +* **packaging:** Don't advertise non-existent ESM entrypoints ([#2022](https://github.com/google/blockly-samples/issues/2022)) ([d019198](https://github.com/google/blockly-samples/commit/d0191984399b784e2928b8fb4c58257bfa857655)), closes [#1877](https://github.com/google/blockly-samples/issues/1877) +* Publish types for field-dependent-dropdown ([#1961](https://github.com/google/blockly-samples/issues/1961)) ([9c96dbe](https://github.com/google/blockly-samples/commit/9c96dbeea63b71ce0f5e8be7b8acaf44e77fc8ed)) +* update tsconfigs to publish types accurately ([df4b084](https://github.com/google/blockly-samples/commit/df4b0844af712f5025a2ec842458b828f3147676)) +* use includes rather than excludes ([40efc25](https://github.com/google/blockly-samples/commit/40efc255329e3ca476ccc247b95a2d05dd77b45e)) + + + +## [3.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.4...@blockly/field-dependent-dropdown@3.0.5) (2023-09-21) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.3...@blockly/field-dependent-dropdown@3.0.4) (2023-09-14) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.2...@blockly/field-dependent-dropdown@3.0.3) (2023-08-17) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.1...@blockly/field-dependent-dropdown@3.0.2) (2023-07-24) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@3.0.0...@blockly/field-dependent-dropdown@3.0.1) (2023-07-20) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [3.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@2.0.1...@blockly/field-dependent-dropdown@3.0.0) (2023-07-13) + + +### ⚠ BREAKING CHANGES + +* updated `plugins/field-date` to TypeScript (#1705) + +### Features + +* updated `plugins/field-date` to TypeScript ([#1705](https://github.com/google/blockly-samples/issues/1705)) ([e5531ff](https://github.com/google/blockly-samples/commit/e5531fffe188ee361a16fe48ed126b34e51a8d30)) + + + +## [2.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@2.0.0...@blockly/field-dependent-dropdown@2.0.1) (2023-07-06) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [2.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@1.1.5...@blockly/field-dependent-dropdown@2.0.0) (2023-06-28) + + +### ⚠ BREAKING CHANGES + +* update all plugins to use blockly-v10.0.0 (#1744) + +### Features + +* update all plugins to use blockly-v10.0.0 ([#1744](https://github.com/google/blockly-samples/issues/1744)) ([6cc88cb](https://github.com/google/blockly-samples/commit/6cc88cbef39d4ad664a668d3d46eb29ba7292f9c)) + + +### Bug Fixes + +* renaming build problems ([7087469](https://github.com/google/blockly-samples/commit/7087469ccdaf27e040cd67a386c9284aa6b1cd67)) + + + +## [1.1.5](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@1.1.4...@blockly/field-dependent-dropdown@1.1.5) (2023-06-22) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [1.1.4](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@1.1.3...@blockly/field-dependent-dropdown@1.1.4) (2023-05-18) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [1.1.3](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@1.1.2...@blockly/field-dependent-dropdown@1.1.3) (2023-05-11) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [1.1.2](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@1.1.1...@blockly/field-dependent-dropdown@1.1.2) (2023-05-04) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## [1.1.1](https://github.com/google/blockly-samples/compare/@blockly/field-dependent-dropdown@1.1.0...@blockly/field-dependent-dropdown@1.1.1) (2023-04-27) + +**Note:** Version bump only for package @blockly/field-dependent-dropdown + + + + + +## 1.1.0 (2023-03-23) + + +### Features + +* Create plugin for dependent dropdown fields. ([#1555](https://github.com/google/blockly-samples/issues/1555)) ([1c473d6](https://github.com/google/blockly-samples/commit/1c473d6b2c4aa3754cf332c07585af60342537ef)) diff --git a/packages/plugins/field-dependent-dropdown/README.md b/packages/plugins/field-dependent-dropdown/README.md new file mode 100644 index 00000000000..0b342767dff --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/README.md @@ -0,0 +1,105 @@ +# @blockly/field-dependent-dropdown [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +A [Blockly](https://www.npmjs.com/package/blockly) dropdown field where the options depend on the value of a parent field. + +## Installation + +### Yarn + +``` +yarn add @blockly/field-dependent-dropdown +``` + +### npm + +``` +npm install @blockly/field-dependent-dropdown --save +``` + +## Usage + +This plugin adds a field type `FieldDependentDropdown` that is an extension of `Blockly.FieldDropdown` and is registered as `'field_dependent_dropdown'` for the JSON API. You can associate it with a parent field that is attached to the same block, along with a mapping from the parent field's possible values to the desired menu options for this child field. Whenever the parent field's value changes, this field will automatically change its own available options to the options that correspond to the new parent value. You can also provide a set of default options that will be used if the parent field's value doesn't match any of the keys in your option mapping. + +These changes are recorded properly in the undo history, and the fields can be [serialized and later deserialized](https://developers.google.com/blockly/guides/configure/web/serialization) while preserving their options, values, and validity. You can also create chains of dependent dropdowns that depend on other dependent dropdowns. + +Note that the parent field must be attached to the block before the child field, and the child field will attach a validator function to the parent field to intercept changes to its value. If you want to add your own [custom field validator](https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators) to the parent field, you need to use [the JavaScript API to define your block](https://developers.google.com/blockly/guides/create-custom-blocks/define/json-and-js) and [pass your validator to the parent field's constructor](https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creating_a_dropdown_validator). If you try to set the parent's validator later, you'll overwrite the one added by this plugin. + +To create a dependent dropdown, you'll need to add this field type to a block definition, and add that block to your toolbox. See below for an example of defining a block that uses this field. + +### JSON + +```js +import * as Blockly from 'blockly'; +import '@blockly/field-dependent-dropdown'; // Import with side effects. + +Blockly.defineBlocksWithJsonArray([ + { + 'type': 'dependent_dropdown_example', + 'message0': 'Category %1 Animal %2', + 'args0': [ + { + 'type': 'field_dropdown', + 'name': 'ANIMAL_CATEGORY', + 'options': [['Mammal', 'mammal'], ['Bird', 'bird'], ['Cryptid', 'cryptid']] + }, + { + 'type': 'field_dependent_dropdown', + 'name': 'ANIMAL', + 'parentName': 'ANIMAL_CATEGORY', + 'optionMapping': { + 'mammal': [['Dog', 'dog'], ['Cat', 'cat'], ['Hamster', 'hamster']], + 'bird': [['Parakeet', 'parakeet'], ['Canary', 'canary']] + } + 'defaultOptions': [['None available', 'noneAvailable']], + } + ] + } +]); +``` + +### JavaScript + +```js +import * as Blockly from 'blockly'; +import {FieldDependentDropdown} from '@blockly/field-dependent-dropdown'; + +Blockly.Blocks['dependent_dropdown_example'] = { + init: function () { + const parentFieldName = 'ANIMAL_CATEGORY'; + const childFieldName = 'ANIMAL'; + const parentOptions = [ + ['Mammal', 'mammal'], + ['Bird', 'bird'], + ['Cryptid', 'cryptid'], + ]; + const optionMapping = { + mammal: [ + ['Dog', 'dog'], + ['Cat', 'cat'], + ['Hamster', 'hamster'], + ], + bird: [ + ['Parakeet', 'parakeet'], + ['Canary', 'canary'], + ], + }; + const defaultOptions = [['None available', 'noneAvailable']]; + this.appendDummyInput() + .appendField('Category') + .appendField(new Blockly.FieldDropdown(parentOptions), parentFieldName) + .appendField('Animal') + .appendField( + new FieldDependentDropdown( + parentFieldName, + optionMapping, + defaultOptions, + ), + childFieldName, + ); + }, +}; +``` + +## License + +Apache 2.0 diff --git a/packages/plugins/field-dependent-dropdown/package.json b/packages/plugins/field-dependent-dropdown/package.json new file mode 100644 index 00000000000..3886e146442 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/package.json @@ -0,0 +1,56 @@ +{ + "name": "@blockly/field-dependent-dropdown", + "version": "13.1.0", + "description": "A Blockly dropdown field that automatically updates its available options depending on the value of another field.", + "scripts": { + "audit:fix": "blockly-scripts auditFix", + "build": "blockly-scripts build", + "clean": "blockly-scripts clean", + "lint": "eslint .", + "predeploy": "blockly-scripts predeploy", + "start": "blockly-scripts start", + "test": "blockly-scripts test" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.js", + "author": "Blockly Team", + "keywords": [ + "blockly", + "colour", + "field", + "hsv", + "slider" + ], + "homepage": "https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/field-dependent-dropdown#readme", + "bugs": { + "url": "https://github.com/RaspberryPiFoundation/blockly/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaspberryPiFoundation/blockly.git", + "directory": "packages/plugins/field-dependent-dropdown" + }, + "license": "Apache-2.0", + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src" + ], + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/field-dependent-dropdown/src/dependent_dropdown_options_change.ts b/packages/plugins/field-dependent-dropdown/src/dependent_dropdown_options_change.ts new file mode 100644 index 00000000000..ec8efd5ed39 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/src/dependent_dropdown_options_change.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview An event representing when a dependent dropdown field changes + * state. + */ + +import * as Blockly from 'blockly/core'; +import type {FieldDependentDropdown} from './field_dependent_dropdown'; + +/** + * A deep equality comparison between the two provided arrays recursively + * comparing any child elements that are also arrays. + * + * @param a The first array to compare. + * @param b The second array to compare. + * @returns Whether the arrays are deeply equivalent. + */ +function arraysAreEquivalent(a: T[], b: T[]): boolean { + return ( + a.length === b.length && + a.every((aElement, index) => { + const bElement = b[index]; + if (Array.isArray(aElement) && Array.isArray(bElement)) { + return arraysAreEquivalent(aElement, bElement); + } + return aElement === bElement; + }) + ); +} + +/** The structure of a serialized DependentDropdownOptionsChange. */ +export interface DependentDropdownOptionsChangeJson + extends Blockly.Events.BlockBaseJson { + name: string; + newValue: string; + oldValue: string; + oldOptions: Blockly.MenuOption[]; + newOptions: Blockly.MenuOption[]; +} + +/** + * A change event representing a simultaneous change to a dropdown field's + * options and value. The old value must be one of the old options, and the new + * value must be one of the new options. Unlike a normal value change event, + * it's possible for this event to change the value to something that wasn't + * previously one of the valid options--in either direction--by also changing + * the options at the same time. + */ +export class DependentDropdownOptionsChange extends Blockly.Events.BlockBase { + /** The name to register with Blockly for the type of event. */ + + static readonly EVENT_TYPE: string = 'dropdown_options_change'; + + /** The name of the change event type for registering with Blockly. */ + readonly type = DependentDropdownOptionsChange.EVENT_TYPE; + + /** The name of the field that changed. */ + name?: string; + + /** The original value of the field. */ + oldValue?: string; + + /** The new value of the field. */ + newValue?: string; + + /** The original available options for the dropdown field. */ + oldOptions?: Blockly.MenuOption[]; + + /** The new available options for the dropdown field. */ + newOptions?: Blockly.MenuOption[]; + + /** + * Construct a new DependentDropdownOptionsChange. + * + * @param block The changed block. Undefined for a blank event. + * @param name Name of the field affected. + * @param oldValue Previous value of field. + * @param newValue New value of field. + * @param oldOptions Previous options for the dropdown. + * @param newOptions New options for the dropdown. + */ + constructor( + block?: Blockly.Block, + name?: string, + oldValue?: string, + newValue?: string, + oldOptions?: Blockly.MenuOption[], + newOptions?: Blockly.MenuOption[], + ) { + super(block); + + if ( + !block || + !name || + !oldValue || + !newValue || + !oldOptions || + !newOptions + ) { + // Blank event to be populated by fromJson. + return; + } + + this.name = name; + this.oldValue = oldValue; + this.newValue = newValue; + this.oldOptions = oldOptions; + this.newOptions = newOptions; + } + + /** + * Encode the event as JSON. + * + * @returns JSON representation. + */ + toJson(): DependentDropdownOptionsChangeJson { + const json = super.toJson() as DependentDropdownOptionsChangeJson; + if ( + !this.name || + !this.oldValue || + !this.newValue || + !this.oldOptions || + !this.newOptions + ) { + throw new Error( + 'The changed element is undefined. Either pass all needed ' + + 'parameters to the constructor, or call fromJson.', + ); + } + json['name'] = this.name; + json['oldValue'] = this.oldValue; + json['newValue'] = this.newValue; + json['oldOptions'] = this.oldOptions; + json['newOptions'] = this.newOptions; + return json; + } + + /** + * Decode the JSON event. + * + * @param json JSON representation. + * @param workspace + * @param event + * @returns The deserialized event. + */ + static fromJson( + json: DependentDropdownOptionsChangeJson, + workspace: Blockly.Workspace, + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + event?: any, + ): DependentDropdownOptionsChange { + const newEvent = super.fromJson( + json, + workspace, + event, + ) as DependentDropdownOptionsChange; + newEvent.name = json['name']; + newEvent.oldValue = json['oldValue']; + newEvent.newValue = json['newValue']; + newEvent.oldOptions = json['oldOptions']; + newEvent.newOptions = json['newOptions']; + return newEvent; + } + + /** + * Does this event leave all state as it was before? + * + * @returns False if something changed. + */ + isNull(): boolean { + const valuesAreEqual = this.oldValue === this.newValue; + const optionsAreEquivalent = + this.oldOptions === this.newOptions || + (Array.isArray(this.oldOptions) && + Array.isArray(this.newOptions) && + arraysAreEquivalent(this.oldOptions, this.newOptions)); + return valuesAreEqual && optionsAreEquivalent; + } + + /** + * Run a change event. + * + * @param forward True if run forward, false if run backward (undo). + */ + run(forward: boolean): void { + if ( + !this.blockId || + !this.name || + !this.oldValue || + !this.newValue || + !this.oldOptions || + !this.newOptions + ) { + console.warn("Can't run uninitialized event."); + return; + } + const workspace = this.getEventWorkspace_(); + const block = workspace.getBlockById(this.blockId); + if (!block) { + console.warn("Can't change non-existent block: " + this.blockId); + return; + } + + const dropdown = block.getField(this.name) as FieldDependentDropdown; + if (!dropdown) { + console.warn("Can't change non-existent dropdown field: " + this.name); + return; + } + + const value = forward ? this.newValue : this.oldValue; + const options = forward ? this.newOptions : this.oldOptions; + + // Record the options on the dropdown for the option generator to access. + dropdown.dependencyData.derivedOptions = options; + + // Re-run the option generator to update the options on the dropdown. + dropdown.getOptions(false); + + // Set the value to one of the now-available options. + dropdown.setValue(value); + } +} + +Blockly.registry.register( + Blockly.registry.Type.EVENT, + DependentDropdownOptionsChange.EVENT_TYPE, + DependentDropdownOptionsChange, +); diff --git a/packages/plugins/field-dependent-dropdown/src/field_dependent_dropdown.ts b/packages/plugins/field-dependent-dropdown/src/field_dependent_dropdown.ts new file mode 100644 index 00000000000..e2348bcd4d6 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/src/field_dependent_dropdown.ts @@ -0,0 +1,319 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview A Blockly dropdown menu field where the options can change + * depending on the value of another field. + */ + +import * as Blockly from 'blockly/core'; +import {DependentDropdownOptionsChange} from './dependent_dropdown_options_change'; + +/** The type of the mapping from parent value to child options. */ +export interface ChildOptionMapping { + [key: string]: Blockly.MenuOption[]; +} + +// This type isn't exported from Blockly so we have to derive it from the API. +type FieldConfig = Exclude< + ConstructorParameters[2], + undefined +>; + +/** fromJson config for a dependent dropdown field. */ +export interface FieldDependentDropdownFromJsonConfig extends FieldConfig { + parentName: string; + optionMapping: ChildOptionMapping; + defaultOptions?: Blockly.MenuOption[]; +} + +/** + * A structure for managing data needed by the menu generator of a + * FieldDependentDropdown. + */ +interface DependencyData { + /** + * A reference to the parent field of an associated dependent dropdown. Absent + * until the parent field is initialized. This field's value determines the + * available options of the child field. + */ + parentField?: Blockly.Field; + + /** + * The child field's currently available menu options based on the current + * value of the parent field. Absent until the parent field is initialized. + */ + derivedOptions?: Blockly.MenuOption[]; +} + +/** + * A dropdown field that automatically updates its own options based on a + * mapping from a parent field's value. The parent field must be attached to the + * block before this child field. + * + * When this field is attached to a block, it will find the parent field and + * attach a validator to the parent field that intercepts changes to its value + * to update the options on this field. If the new validator is later removed\ + * or replaced, then this dependent field will no longer function. + */ +export class FieldDependentDropdown extends Blockly.FieldDropdown { + /** + * Contains data used by this dropdown field's menu generator. + * + * The menu generator cannot refer directly to this FieldDependentDropdown + * instance, because it must be created before calling the super constructor + * when the "this" reference is not yet valid. This helper structure is used + * instead. + * + * This is public so that the DependentDropdownOptionsChangeJson event can + * update it while undoing/redoing. + */ + dependencyData: DependencyData; + + /** The name of the field that determines this field's options. */ + private parentName: string; + + /** + * The mapping from the parent field's value to this field's intended + * available options. The keys are strings representing the parent's possible + * values, and the values are the corresponding options to use in this child + * field. + */ + private optionMapping: ChildOptionMapping; + + /** + * An optional fallback set of options to use if the parent field's value does + * not match any of the keys in optionMapping. + */ + private defaultOptions?: Blockly.MenuOption[]; + + /** + * Constructs a new FieldDependentDropdown. + * + * @param parentName The name of the parent field whose value determines this + * field's available options. + * @param optionMapping A mapping from the possible values of the parent field + * to the corresponding available options of this child field. The keys are + * the possible values of the parent field, and the values are the + * corresponding arrays of options for this child field. + * @param defaultOptions An optional fallback set of options to use if the + * parent field's value does not match any of the keys in optionMapping. + * @param validator An optional function that is called to validate changes to + * this field's value. + * @param config An optional map of general options used to configure the + * field, such as a tooltip. + */ + constructor( + parentName: string, + optionMapping: ChildOptionMapping, + defaultOptions?: Blockly.MenuOption[], + validator?: Blockly.FieldValidator, + config?: FieldConfig, + ) { + // A menu generator needs to be passed to the super constructor, but it + // needs to be able to reference data that hasn't been populated yet. We're + // not allowed to refer to "this" in this constructor before calling + // "super", so let's make separate structure to hold data relevant to the + // menu generator and populate that later. + const dependencyData: DependencyData = {}; + + // A menu option generator function for this child field that reads the + // derived options in the dependency data if available. + const menuGenerator: Blockly.MenuGeneratorFunction = () => { + // If derivedOptions has been initialized, use that. + if (dependencyData.derivedOptions) { + return dependencyData.derivedOptions; + } + + // Fall back on the options corresponding to the parent field's current + // value (which is fine when initializing but may be out of date when + // making changes since the parent field's validator function triggers + // this function before the parent field's value is updated). + + // If the parent field exists, and its value is a key in the provided + // option mapping, use the corresponding options. + if (dependencyData.parentField) { + const value = dependencyData.parentField.getValue(); + if (value) { + const options = optionMapping[value]; + if (options) { + return options; + } + } + } + + if (defaultOptions) { + return defaultOptions; + } + + // Fall back on basic default options. + return [['', '']]; + }; + + super(menuGenerator, validator, config); + this.parentName = parentName; + this.optionMapping = optionMapping; + this.defaultOptions = defaultOptions; + this.dependencyData = dependencyData; + } + + /** + * Constructs a FieldDependentDropdown from a JSON arg object. + * + * @param options A JSON object providing "parentName" and "optionMapping". + * @returns The new field instance. + */ + static fromJson( + options: FieldDependentDropdownFromJsonConfig, + ): FieldDependentDropdown { + return new FieldDependentDropdown( + options['parentName'], + options['optionMapping'], + options['defaultOptions'], + undefined, + options, + ); + } + + /** + * Attach this field to a block. + * + * @param block The block containing this field. + */ + setSourceBlock(block: Blockly.Block) { + super.setSourceBlock(block); + + const parentField: Blockly.Field | null = block.getField( + this.parentName, + ); + + if (!parentField) { + throw new Error( + 'Could not find a parent field with the name ' + + this.parentName + + ' for the dependent dropdown.', + ); + } + + this.dependencyData.parentField = parentField; + + const oldValidator = parentField.getValidator(); + + // A validator function for the parent field that has the side effect of + // updating the options of this child dropdown field based on the new value + // of the parent field whenever it changes. The validator function is a good + // place to do this because it is called immediately while deserializing + // workspaces before the following fields are deserialized, so when the + // child value is deserialized the appropriate options will already be + // available. If the parent already had a validator function, it will be + // composed with this one and the new value returned from it will be the + // basis for determining the new available options. + parentField.setValidator((newValue) => { + if (oldValidator) { + const validatedValue = oldValidator(newValue); + // If a validator returns null, that means the new value is invalid and + // the change should be canceled. + if (validatedValue === null) { + return null; + } + // If a validator returns undefined, that means no change. Otherwise, + // use the returned value as the new value. + if (validatedValue !== undefined) { + newValue = validatedValue; + } + } + this.updateOptionsBasedOnNewValue(newValue); + return newValue; + }); + this.updateOptionsBasedOnNewValue(parentField.getValue() ?? undefined); + } + + /** + * Updates the options of this child dropdown field based on the new value of + * the parent field. + * + * @param newValue The newly assigned value. + */ + private updateOptionsBasedOnNewValue(newValue: string | undefined): void { + if (newValue == undefined) { + return; + } + + const block = this.getSourceBlock(); + if (!block) { + throw new Error( + 'Could not validate a field that is not attached to a block: ' + + this.name, + ); + } + + const oldChildValue = this.getValue(); + const oldChildOptions = this.getOptions(false); + let newChildOptions = this.optionMapping[newValue]; + if (!newChildOptions) { + if (this.defaultOptions) { + newChildOptions = this.defaultOptions; + } else { + console.warn( + 'Could not find child options for the parent value: ' + newValue, + ); + return; + } + } + + // If the child field's value is still available in the new options, keep + // it, otherwise change the field's value to the first available option. + const newOptionsIncludeOldValue = + newChildOptions.find((option) => option[1] == oldChildValue) != undefined; + const newChildValue = newOptionsIncludeOldValue + ? oldChildValue + : newChildOptions[0][1]; + + // Record the options so that the option generator can access them. + this.dependencyData.derivedOptions = newChildOptions; + + // Re-run the option generator to update the options on the dropdown. + this.getOptions(false); + + // Update this child field's value without broadcasting the normal change + // event. The normal value change event can't be properly undone, because + // the old value may not be one of the currently valid options, so a custom + // change event will be broadcast instead that handles swapping the options + // and the value at the same time. + Blockly.Events.disable(); + this.setValue(newChildValue); + Blockly.Events.enable(); + + if (Blockly.Events.getRecordUndo()) { + if (!Blockly.Events.getGroup()) { + // Start a change group before the change event. The change event for + // the parent field value will be created after this function returns + // and will be part of the same group. + Blockly.Events.setGroup(true); + // Clear the change group later, after all related events have been + // broadcast, but before the user performs any more actions. + setTimeout(() => Blockly.Events.setGroup(false)); + } + + // Record that the child field's options and value have changed. + Blockly.Events.fire( + new DependentDropdownOptionsChange( + block, + this.name, + oldChildValue ?? undefined, + newChildValue ?? undefined, + oldChildOptions, + newChildOptions, + ), + ); + } + } +} + +Blockly.fieldRegistry.register( + 'field_dependent_dropdown', + FieldDependentDropdown, +); diff --git a/packages/plugins/field-dependent-dropdown/src/index.ts b/packages/plugins/field-dependent-dropdown/src/index.ts new file mode 100644 index 00000000000..e837ff51ff9 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/src/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './field_dependent_dropdown'; diff --git a/packages/plugins/field-dependent-dropdown/test/field_dependent_dropdown.mocha.js b/packages/plugins/field-dependent-dropdown/test/field_dependent_dropdown.mocha.js new file mode 100644 index 00000000000..61d32499a69 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/test/field_dependent_dropdown.mocha.js @@ -0,0 +1,302 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const chai = require('chai'); +const sinon = require('sinon'); +const Blockly = require('blockly'); +require('./field_dependent_dropdown_test_block'); + +const assert = chai.assert; + +suite('fieldDependentDropdown', function () { + setup(function () { + this.workspace = new Blockly.Workspace(); + this.clock = sinon.useFakeTimers(); + }); + + teardown(function () { + this.workspace.dispose(); + // Finish any remaining queued events then dispose the sinon environment. + this.clock.runAll(); + this.clock.restore(); + }); + + test('Changing a parent value changes the child options', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + assert.deepEqual(childDropdown.getOptions(true), [ + ['A1', 'a1'], + ['A2', 'a2'], + ['Shared', 'shared'], + ]); + parentDropdown.setValue('b'); + assert.deepEqual(childDropdown.getOptions(true), [ + ['B1', 'b1'], + ['B2', 'b2'], + ['Shared', 'shared'], + ]); + }); + + test('Changing a parent value changes the child value', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + assert.equal(childDropdown.getValue(), 'a1'); + parentDropdown.setValue('b'); + assert.equal(childDropdown.getValue(), 'b1'); + }); + + test('Changing a parent value preserves shared child value', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + childDropdown.setValue('shared'); + parentDropdown.setValue('b'); + assert.equal(childDropdown.getValue(), 'shared'); + }); + + test('Changing a parent value changes the grandchild options', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + assert.deepEqual(grandchildDropdown.getOptions(true), [ + ['A11', 'a11'], + ['A12', 'a12'], + ]); + parentDropdown.setValue('b'); + assert.deepEqual(grandchildDropdown.getOptions(true), [ + ['B11', 'b11'], + ['B12', 'b12'], + ]); + }); + + test('Changing a parent value changes the grandchild value', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + assert.equal(grandchildDropdown.getValue(), 'a11'); + parentDropdown.setValue('b'); + assert.equal(grandchildDropdown.getValue(), 'b11'); + }); + + test('Uses default options if parent field not in mapping', function () { + const block = this.workspace.newBlock( + 'dependent_dropdown_default_options_test', + ); + const parentTextInput = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + assert.deepEqual(childDropdown.getOptions(true), [ + ['Default Option', 'defaultOption'], + ]); + parentTextInput.setValue('a'); + assert.deepEqual(childDropdown.getOptions(true), [ + ['A1', 'a1'], + ['A2', 'a2'], + ]); + parentTextInput.setValue('b'); + assert.deepEqual(childDropdown.getOptions(true), [ + ['Default Option', 'defaultOption'], + ]); + }); + + test('Parent field user validator composes with new validator', function () { + const block = this.workspace.newBlock('dependent_dropdown_validation_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + assert.equal(parentDropdown.getValue(), 'initial'); + assert.equal(childDropdown.getValue(), 'initial1'); + parentDropdown.setValue('invalid'); + assert.equal(parentDropdown.getValue(), 'valid'); + assert.equal(childDropdown.getValue(), 'valid1'); + }); + + test('undoing parent change undoes child and grandchild options', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + const grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + parentDropdown.setValue('b'); + assert.deepEqual(childDropdown.getOptions(true), [ + ['B1', 'b1'], + ['B2', 'b2'], + ['Shared', 'shared'], + ]); + assert.deepEqual(grandchildDropdown.getOptions(true), [ + ['B11', 'b11'], + ['B12', 'b12'], + ]); + // Wait for the change events to get fired and recorded in history. + this.clock.runAll(); + this.workspace.undo(false); + assert.deepEqual(childDropdown.getOptions(true), [ + ['A1', 'a1'], + ['A2', 'a2'], + ['Shared', 'shared'], + ]); + assert.deepEqual(grandchildDropdown.getOptions(true), [ + ['A11', 'a11'], + ['A12', 'a12'], + ]); + }); + + test('undoing parent change undoes child and grandchild values', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + const grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + parentDropdown.setValue('b'); + assert.equal(childDropdown.getValue(), 'b1'); + assert.equal(grandchildDropdown.getValue(), 'b11'); + // Wait for the change events to get fired and recorded in history. + this.clock.runAll(); + this.workspace.undo(false); + assert.equal(childDropdown.getValue(), 'a1'); + assert.equal(grandchildDropdown.getValue(), 'a11'); + }); + + test('redoing parent change redoes child options', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + parentDropdown.setValue('b'); + // Wait for the change events to get fired and recorded in history. + this.clock.runAll(); + this.workspace.undo(false); + assert.deepEqual(childDropdown.getOptions(true), [ + ['A1', 'a1'], + ['A2', 'a2'], + ['Shared', 'shared'], + ]); + this.workspace.undo(true); + assert.deepEqual(childDropdown.getOptions(true), [ + ['B1', 'b1'], + ['B2', 'b2'], + ['Shared', 'shared'], + ]); + }); + + test('redoing parent change redoes child values', function () { + const block = this.workspace.newBlock('dependent_dropdown_test'); + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + parentDropdown.setValue('b'); + // Wait for the change events to get fired and recorded in history. + this.clock.runAll(); + this.workspace.undo(false); + assert.equal(childDropdown.getValue(), 'a1'); + this.workspace.undo(true); + assert.equal(childDropdown.getValue(), 'b1'); + }); + + test('deserialized values affect available options', function () { + const serializedWorkspace = { + blocks: { + blocks: [ + { + type: 'dependent_dropdown_test', + fields: { + PARENT_FIELD: 'b', + CHILD_FIELD: 'b2', + GRANDCHILD_FIELD: 'b21', + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(serializedWorkspace, this.workspace); + const block = this.workspace.getBlocksByType( + 'dependent_dropdown_test', + false, + )[0]; + const childDropdown = block.getField('CHILD_FIELD'); + const grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + assert.deepEqual(childDropdown.getOptions(true), [ + ['B1', 'b1'], + ['B2', 'b2'], + ['Shared', 'shared'], + ]); + assert.deepEqual(grandchildDropdown.getOptions(true), [ + ['B21', 'b21'], + ['B22', 'b22'], + ]); + }); + + test('deserializing preserves values not in default options', function () { + const serializedWorkspace = { + blocks: { + blocks: [ + { + type: 'dependent_dropdown_test', + fields: { + PARENT_FIELD: 'b', + CHILD_FIELD: 'b2', + GRANDCHILD_FIELD: 'b21', + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(serializedWorkspace, this.workspace); + const block = this.workspace.getBlocksByType( + 'dependent_dropdown_test', + false, + )[0]; + const parentDropdown = block.getField('PARENT_FIELD'); + const childDropdown = block.getField('CHILD_FIELD'); + const grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + assert.equal(parentDropdown.getValue(), 'b'); + assert.equal(childDropdown.getValue(), 'b2'); + assert.equal(grandchildDropdown.getValue(), 'b21'); + }); + + test('deserializing invalid value replaces with valid value', function () { + const serializedWorkspace = { + blocks: { + blocks: [ + { + type: 'dependent_dropdown_test', + fields: { + PARENT_FIELD: 'b', + CHILD_FIELD: 'a2', + GRANDCHILD_FIELD: 'b21', + }, + }, + ], + }, + }; + Blockly.serialization.workspaces.load(serializedWorkspace, this.workspace); + const block = this.workspace.getBlocksByType( + 'dependent_dropdown_test', + false, + )[0]; + const childDropdown = block.getField('CHILD_FIELD'); + const grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + assert.equal(childDropdown.getValue(), 'b1'); + assert.equal(grandchildDropdown.getValue(), 'b11'); + }); + + test('round trip serialization/deserialization preserves values', function () { + let block = this.workspace.newBlock('dependent_dropdown_test'); + let parentDropdown = block.getField('PARENT_FIELD'); + let childDropdown = block.getField('CHILD_FIELD'); + let grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + parentDropdown.setValue('b'); + childDropdown.setValue('b2'); + grandchildDropdown.setValue('b21'); + const serializedWorkspace = Blockly.serialization.workspaces.save( + this.workspace, + ); + Blockly.serialization.workspaces.load(serializedWorkspace, this.workspace); + block = this.workspace.getBlocksByType('dependent_dropdown_test', false)[0]; + parentDropdown = block.getField('PARENT_FIELD'); + childDropdown = block.getField('CHILD_FIELD'); + grandchildDropdown = block.getField('GRANDCHILD_FIELD'); + assert.equal(parentDropdown.getValue(), 'b'); + assert.equal(childDropdown.getValue(), 'b2'); + assert.equal(grandchildDropdown.getValue(), 'b21'); + }); +}); diff --git a/packages/plugins/field-dependent-dropdown/test/field_dependent_dropdown_test_block.ts b/packages/plugins/field-dependent-dropdown/test/field_dependent_dropdown_test_block.ts new file mode 100644 index 00000000000..2cf7c180490 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/test/field_dependent_dropdown_test_block.ts @@ -0,0 +1,130 @@ +import * as Blockly from 'blockly'; +import {ChildOptionMapping, FieldDependentDropdown} from '../src/index'; + +Blockly.defineBlocksWithJsonArray([ + { + type: 'dependent_dropdown_test', + message0: 'Parent %1 Child %2 Grandchild %3', + args0: [ + { + type: 'field_dropdown', + name: 'PARENT_FIELD', + options: [ + ['A', 'a'], + ['B', 'b'], + ], + }, + { + type: 'field_dependent_dropdown', + name: 'CHILD_FIELD', + parentName: 'PARENT_FIELD', + optionMapping: { + a: [ + ['A1', 'a1'], + ['A2', 'a2'], + ['Shared', 'shared'], + ], + b: [ + ['B1', 'b1'], + ['B2', 'b2'], + ['Shared', 'shared'], + ], + }, + }, + { + type: 'field_dependent_dropdown', + name: 'GRANDCHILD_FIELD', + parentName: 'CHILD_FIELD', + optionMapping: { + a1: [ + ['A11', 'a11'], + ['A12', 'a12'], + ], + a2: [ + ['A21', 'a21'], + ['A22', 'a22'], + ], + b1: [ + ['B11', 'b11'], + ['B12', 'b12'], + ], + b2: [ + ['B21', 'b21'], + ['B22', 'b22'], + ], + shared: [['Only', 'only']], + }, + }, + ], + colour: 100, + }, + { + type: 'dependent_dropdown_default_options_test', + message0: 'Parent %1 Child %2', + args0: [ + { + type: 'field_input', + name: 'PARENT_FIELD', + }, + { + type: 'field_dependent_dropdown', + name: 'CHILD_FIELD', + parentName: 'PARENT_FIELD', + optionMapping: { + a: [ + ['A1', 'a1'], + ['A2', 'a2'], + ], + }, + defaultOptions: [['Default Option', 'defaultOption']], + }, + ], + colour: 100, + }, +]); + +Blockly.Blocks['dependent_dropdown_validation_test'] = { + init: function () { + const parentFieldName = 'PARENT_FIELD'; + const childFieldName = 'CHILD_FIELD'; + const parentOptions: Blockly.MenuOption[] = [ + ['Initial', 'initial'], + ['Invalid', 'invalid'], + ['Valid', 'valid'], + ]; + const dependentOptions: ChildOptionMapping = { + initial: [ + ['Initial1', 'initial1'], + ['Initial2', 'initial2'], + ], + invalid: [ + ['Invalid1', 'invalid1'], + ['Invalid2', 'invalid2'], + ], + valid: [ + ['Valid1', 'valid1'], + ['Valid2', 'valid2'], + ], + }; + const parentValidator: Blockly.FieldDropdownValidator = function ( + newValue, + ) { + if (newValue == 'invalid') { + return 'valid'; + } + return undefined; + }; + this.appendDummyInput() + .appendField('Parent') + .appendField( + new Blockly.FieldDropdown(parentOptions, parentValidator), + parentFieldName, + ) + .appendField('Child') + .appendField( + new FieldDependentDropdown(parentFieldName, dependentOptions), + childFieldName, + ); + this.setColour(100); + }, +}; diff --git a/packages/plugins/field-dependent-dropdown/test/index.html b/packages/plugins/field-dependent-dropdown/test/index.html new file mode 100644 index 00000000000..eb2b560a777 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/test/index.html @@ -0,0 +1,18 @@ + + + + + Blockly Dependent Dropdown Field Playground + + + + +
+ + + diff --git a/packages/plugins/field-dependent-dropdown/test/index.ts b/packages/plugins/field-dependent-dropdown/test/index.ts new file mode 100644 index 00000000000..c69b2e98ce3 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/test/index.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Dependent dropdown field test playground. + */ + +import * as Blockly from 'blockly'; +import {createPlayground} from '@blockly/dev-tools'; +import './field_dependent_dropdown_test_block'; + +const toolbox: Blockly.utils.toolbox.ToolboxDefinition = { + kind: 'flyoutToolbox', + contents: [ + { + kind: 'block', + type: 'dependent_dropdown_test', + fields: { + /* eslint-disable @typescript-eslint/naming-convention */ + PARENT_FIELD: 'b', + CHILD_FIELD: 'b2', + GRANDCHILD_FIELD: 'b21', + /* eslint-enable @typescript-eslint/naming-convention */ + }, + }, + { + kind: 'block', + type: 'dependent_dropdown_default_options_test', + }, + { + kind: 'block', + type: 'dependent_dropdown_validation_test', + }, + ], +}; + +/** + * Create a workspace. + * + * @param blocklyDiv The blockly container div. + * @param options The Blockly options. + * @returns The created workspace. + */ +function createWorkspace( + blocklyDiv: HTMLElement, + options: Blockly.BlocklyOptions, +): Blockly.WorkspaceSvg { + const workspace = Blockly.inject(blocklyDiv, options); + return workspace; +} + +document.addEventListener('DOMContentLoaded', function () { + const defaultOptions: Blockly.BlocklyOptions = { + toolbox, + }; + const rootElement = document.getElementById('root'); + if (rootElement) { + createPlayground(rootElement, createWorkspace, defaultOptions); + } +}); diff --git a/packages/plugins/field-dependent-dropdown/tsconfig.json b/packages/plugins/field-dependent-dropdown/tsconfig.json new file mode 100644 index 00000000000..99858c525f1 --- /dev/null +++ b/packages/plugins/field-dependent-dropdown/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "es2015", + "moduleResolution": "bundler", + "target": "es6", + "strict": true + }, + // NOTE: `test/**/*` is automatically included in `blockly-scripts start`. + // Only src matters for production builds. + "include": ["src"] +} diff --git a/packages/plugins/field-grid-dropdown/CHANGELOG.md b/packages/plugins/field-grid-dropdown/CHANGELOG.md new file mode 100644 index 00000000000..327ffdc4237 --- /dev/null +++ b/packages/plugins/field-grid-dropdown/CHANGELOG.md @@ -0,0 +1,681 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [13.1.0](https://github.com/RaspberryPiFoundation/blockly-samples/compare/v13.0.0...v13.1.0) (2026-06-30) + +### ⚠ BREAKING CHANGES + +* Bump Blockly dependency to v13 (#2704) + +### Bug Fixes + +* Improve accessibility of grid and colour fields ([#2724](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2724)) + +### Miscellaneous Chores + +* Bump Blockly dependency to v13 ([#2704](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2704)) + + +## [6.0.10](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.9...@blockly/field-grid-dropdown@6.0.10) (2026-04-09) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [6.0.9](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.8...@blockly/field-grid-dropdown@6.0.9) (2026-01-15) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [6.0.8](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.7...@blockly/field-grid-dropdown@6.0.8) (2026-01-05) + + +### Bug Fixes + +* update plugin URLs to RPF ([#2665](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2665)) ([6c34039](https://github.com/RaspberryPiFoundation/blockly-samples/commit/6c3403960b8070eb8e576bd5702bdb53a09ac475)) + + + +## [6.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.6...@blockly/field-grid-dropdown@6.0.7) (2026-01-05) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [6.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.5...@blockly/field-grid-dropdown@6.0.6) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [6.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.4...@blockly/field-grid-dropdown@6.0.5) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [6.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.3...@blockly/field-grid-dropdown@6.0.4) (2025-10-02) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [6.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.2...@blockly/field-grid-dropdown@6.0.3) (2025-07-17) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [6.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.1...@blockly/field-grid-dropdown@6.0.2) (2025-07-03) + + +### Bug Fixes + +* prevent Enter/Space from propagating from Grid ([#2570](https://github.com/google/blockly-samples/issues/2570)) ([25ee5a9](https://github.com/google/blockly-samples/commit/25ee5a9a750d819c9dbf00d6513f3b9c6fd302fe)) + + + +## [6.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@6.0.0...@blockly/field-grid-dropdown@6.0.1) (2025-05-22) + + +### Bug Fixes + +* **field-grid-dropdown:** add overflow:auto to enable scroll in grid dropdown container ([#2543](https://github.com/google/blockly-samples/issues/2543)) ([f38078d](https://github.com/google/blockly-samples/commit/f38078d6449e40903a4725b14d57aabf414e5bf5)) + + + +## [6.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.16...@blockly/field-grid-dropdown@6.0.0) (2025-05-16) + + +### ⚠ BREAKING CHANGES + +* update all plugins to blockly v12 (#2538) + +### Features + +* update all plugins to blockly v12 ([#2538](https://github.com/google/blockly-samples/issues/2538)) ([420ba31](https://github.com/google/blockly-samples/commit/420ba3124b260b2c06e8e1d61be44e870780d95f)) + + + +## [5.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.15...@blockly/field-grid-dropdown@5.0.16) (2025-05-16) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.14...@blockly/field-grid-dropdown@5.0.15) (2025-05-15) + + +### Bug Fixes + +* **FieldGridDropdown:** Handle presence of HTMLElement in MenuOptions ([43bdfab](https://github.com/google/blockly-samples/commit/43bdfabeb658331d3991430ec92589ba415dac75)) +* Fix the field-grid-dropdown tests. ([452051e](https://github.com/google/blockly-samples/commit/452051e02520803e7cf85e63e0bfb8191a838a98)) + + + + + +## [5.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.13...@blockly/field-grid-dropdown@5.0.14) (2025-02-13) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.12...@blockly/field-grid-dropdown@5.0.13) (2024-12-19) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.11...@blockly/field-grid-dropdown@5.0.12) (2024-12-03) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.10...@blockly/field-grid-dropdown@5.0.11) (2024-11-07) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.9...@blockly/field-grid-dropdown@5.0.10) (2024-09-26) + + +### Bug Fixes + +* **field-*:** Remove unneeded `fieldRegistry.unregister` calls ([#2454](https://github.com/google/blockly-samples/issues/2454)) ([b3ba30e](https://github.com/google/blockly-samples/commit/b3ba30e23dddf0bd98c266659aa229ba6ba685b0)), closes [#2453](https://github.com/google/blockly-samples/issues/2453) + + + + + +## [5.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.8...@blockly/field-grid-dropdown@5.0.9) (2024-08-29) + + +### Bug Fixes + +* predeploy plugins ([#2449](https://github.com/google/blockly-samples/issues/2449)) ([6b36d8b](https://github.com/google/blockly-samples/commit/6b36d8b344a969f79d89bbc7dcee29ae554759f9)) + + + + + +## [5.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.7...@blockly/field-grid-dropdown@5.0.8) (2024-08-22) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.6...@blockly/field-grid-dropdown@5.0.7) (2024-08-08) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.5...@blockly/field-grid-dropdown@5.0.6) (2024-08-01) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.4...@blockly/field-grid-dropdown@5.0.5) (2024-07-25) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.3...@blockly/field-grid-dropdown@5.0.4) (2024-07-11) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.2...@blockly/field-grid-dropdown@5.0.3) (2024-07-04) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.1...@blockly/field-grid-dropdown@5.0.2) (2024-06-06) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [5.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@5.0.0...@blockly/field-grid-dropdown@5.0.1) (2024-05-30) + + +### Bug Fixes + +* multiple blockly instances ([#2375](https://github.com/google/blockly-samples/issues/2375)) ([b231e59](https://github.com/google/blockly-samples/commit/b231e598f2f5f5b0abbfd01d981e35572ad50a26)) + + + +## [5.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.16...@blockly/field-grid-dropdown@5.0.0) (2024-05-21) + + +### ⚠ BREAKING CHANGES + +* update all plugins to v11 of blockly + +### Features + +* update all plugins to v11 of blockly ([5fcd3e7](https://github.com/google/blockly-samples/commit/5fcd3e7d53eaadffe9bda9a378b404d34b2f8be2)) +* upgrade ts version, fix field validators ([b2d2191](https://github.com/google/blockly-samples/commit/b2d2191ff11b30347b33f95acf6e58f7ce54d004)) + + +### Bug Fixes + +* Call blocks handle both manual disabling and disabled defs ([#2334](https://github.com/google/blockly-samples/issues/2334)) ([5eade55](https://github.com/google/blockly-samples/commit/5eade55779c4022d14ad4472ff32c93c78199887)) +* remove some uses of generateFieldTextBlocks ([#2357](https://github.com/google/blockly-samples/issues/2357)) ([53b3fa4](https://github.com/google/blockly-samples/commit/53b3fa4604aabb1fc5240d41c8d1d389d4b15301)) + + + +## [4.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.15...@blockly/field-grid-dropdown@4.0.16) (2024-05-16) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.14...@blockly/field-grid-dropdown@4.0.15) (2024-05-09) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.13...@blockly/field-grid-dropdown@4.0.14) (2024-04-25) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.12...@blockly/field-grid-dropdown@4.0.13) (2024-04-04) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.11...@blockly/field-grid-dropdown@4.0.12) (2024-03-28) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.10...@blockly/field-grid-dropdown@4.0.11) (2024-02-08) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.9...@blockly/field-grid-dropdown@4.0.10) (2023-12-14) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.8...@blockly/field-grid-dropdown@4.0.9) (2023-12-07) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.7...@blockly/field-grid-dropdown@4.0.8) (2023-11-09) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.6...@blockly/field-grid-dropdown@4.0.7) (2023-11-02) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.5...@blockly/field-grid-dropdown@4.0.6) (2023-10-30) + + +### Bug Fixes + +* add the type property to the package.json next to the "main" property: ([#1955](https://github.com/google/blockly-samples/issues/1955)) ([abc713b](https://github.com/google/blockly-samples/commit/abc713b5f3c2941dd4e3a6cbde9060a19b80d189)) +* **packaging:** Don't advertise non-existent ESM entrypoints ([#2022](https://github.com/google/blockly-samples/issues/2022)) ([d019198](https://github.com/google/blockly-samples/commit/d0191984399b784e2928b8fb4c58257bfa857655)), closes [#1877](https://github.com/google/blockly-samples/issues/1877) +* update tsconfigs to publish types accurately ([df4b084](https://github.com/google/blockly-samples/commit/df4b0844af712f5025a2ec842458b828f3147676)) +* use includes rather than excludes ([40efc25](https://github.com/google/blockly-samples/commit/40efc255329e3ca476ccc247b95a2d05dd77b45e)) + + + +## [4.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.4...@blockly/field-grid-dropdown@4.0.5) (2023-09-21) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.3...@blockly/field-grid-dropdown@4.0.4) (2023-09-14) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.2...@blockly/field-grid-dropdown@4.0.3) (2023-08-17) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.1...@blockly/field-grid-dropdown@4.0.2) (2023-07-24) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@4.0.0...@blockly/field-grid-dropdown@4.0.1) (2023-07-20) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [4.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@3.0.1...@blockly/field-grid-dropdown@4.0.0) (2023-07-13) + + +### ⚠ BREAKING CHANGES + +* updated `plugins/field-date` to TypeScript (#1705) + +### Features + +* updated `plugins/field-date` to TypeScript ([#1705](https://github.com/google/blockly-samples/issues/1705)) ([e5531ff](https://github.com/google/blockly-samples/commit/e5531fffe188ee361a16fe48ed126b34e51a8d30)) + + + +## [3.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@3.0.0...@blockly/field-grid-dropdown@3.0.1) (2023-07-06) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [3.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.18...@blockly/field-grid-dropdown@3.0.0) (2023-06-28) + + +### ⚠ BREAKING CHANGES + +* update all plugins to use blockly-v10.0.0 (#1744) + +### Features + +* update all plugins to use blockly-v10.0.0 ([#1744](https://github.com/google/blockly-samples/issues/1744)) ([6cc88cb](https://github.com/google/blockly-samples/commit/6cc88cbef39d4ad664a668d3d46eb29ba7292f9c)) + + + +## [2.0.18](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.17...@blockly/field-grid-dropdown@2.0.18) (2023-06-22) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.16...@blockly/field-grid-dropdown@2.0.17) (2023-05-18) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.15...@blockly/field-grid-dropdown@2.0.16) (2023-05-11) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.14...@blockly/field-grid-dropdown@2.0.15) (2023-05-04) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.13...@blockly/field-grid-dropdown@2.0.14) (2023-04-27) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.12...@blockly/field-grid-dropdown@2.0.13) (2023-04-13) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.11...@blockly/field-grid-dropdown@2.0.12) (2023-03-23) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.10...@blockly/field-grid-dropdown@2.0.11) (2023-03-09) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.9...@blockly/field-grid-dropdown@2.0.10) (2023-03-02) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.8...@blockly/field-grid-dropdown@2.0.9) (2023-02-23) + + +### Bug Fixes + +* remove prepublishOnly scripts ([#1579](https://github.com/google/blockly-samples/issues/1579)) ([27da6cd](https://github.com/google/blockly-samples/commit/27da6cd04c38f6ba417f4e7446bb6218c475448d)) + + + +## [2.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.7...@blockly/field-grid-dropdown@2.0.8) (2023-02-23) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.6...@blockly/field-grid-dropdown@2.0.7) (2023-02-16) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.5...@blockly/field-grid-dropdown@2.0.6) (2023-02-09) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.4...@blockly/field-grid-dropdown@2.0.5) (2023-01-26) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.3...@blockly/field-grid-dropdown@2.0.4) (2023-01-05) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.2...@blockly/field-grid-dropdown@2.0.3) (2022-12-15) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.1...@blockly/field-grid-dropdown@2.0.2) (2022-10-27) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@2.0.0...@blockly/field-grid-dropdown@2.0.1) (2022-10-13) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## [2.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-grid-dropdown@1.0.47...@blockly/field-grid-dropdown@2.0.0) (2022-10-05) + + +### ⚠ BREAKING CHANGES + +* update peer and devDependencies of all plugins to require Blockly v9 (#1314) + +### Bug Fixes + +* references to deprecated functions in v9 ([#1313](https://github.com/google/blockly-samples/issues/1313)) ([cb2e679](https://github.com/google/blockly-samples/commit/cb2e67987e0b62a77c26adc660cc6ade1ba67954)) + + +### Miscellaneous Chores + +* update peer and devDependencies of all plugins to require Blockly v9 ([#1314](https://github.com/google/blockly-samples/issues/1314)) ([03d4912](https://github.com/google/blockly-samples/commit/03d4912c42c8de0f30493037ccc28dddaea0f266)) + + + +## 1.0.47 (2022-08-11) + + +### Bug Fixes + +* Remove the deprecated block-extension-tooltip ([#1215](https://github.com/google/blockly-samples/issues/1215)) ([a044478](https://github.com/google/blockly-samples/commit/a044478c86a73e3065bc866e427f175cbec6fc13)) + + + + + +## 1.0.46 (2022-08-04) + + +### Bug Fixes + +* fix the name of the package of suggested-blocks ([#1214](https://github.com/google/blockly-samples/issues/1214)) ([35aa8ec](https://github.com/google/blockly-samples/commit/35aa8ec73a60a4eb5b1e80cb2fc71dcd83d05e27)) + + + + + +## 1.0.44 (2022-07-21) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## 1.0.43 (2022-07-06) + + +### Bug Fixes + +* Update blockly-react to use functional components instead of class components. ([#1178](https://github.com/google/blockly-samples/issues/1178)) ([fa21187](https://github.com/google/blockly-samples/commit/fa21187cdbe4ec3a5c69f185540dd68a98eb69d7)) + + + + + +## 1.0.42 (2022-06-27) + + +### Bug Fixes + +* Update package import for keyboard navigation demo ([#1170](https://github.com/google/blockly-samples/issues/1170)) ([69c1725](https://github.com/google/blockly-samples/commit/69c1725b775279fcc397dc178935208d5f42b08c)) + + + + + +## 1.0.41 (2022-06-21) + +**Note:** Version bump only for package @blockly/field-grid-dropdown + + + + + +## 1.0.40 (2022-06-08) + + +### Bug Fixes + +* package versions to support patch releases ([#1150](https://github.com/google/blockly-samples/issues/1150)) ([e1ae378](https://github.com/google/blockly-samples/commit/e1ae378d779531621c3d948566257d069002963f)) + + + + + +## 1.0.39 (2022-06-02) + + +### Bug Fixes + +* add git identity ([#1156](https://github.com/google/blockly-samples/issues/1156)) ([8d80924](https://github.com/google/blockly-samples/commit/8d809243b277375beb2ce75d4e157b5e17f78193)) diff --git a/packages/plugins/field-grid-dropdown/README.md b/packages/plugins/field-grid-dropdown/README.md new file mode 100644 index 00000000000..90b8dd6ac98 --- /dev/null +++ b/packages/plugins/field-grid-dropdown/README.md @@ -0,0 +1,92 @@ +# @blockly/field-grid-dropdown [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +A [Blockly](https://www.npmjs.com/package/blockly) dropdown field with grid layout. + +![](https://github.com/RaspberryPiFoundation/blockly-samples/raw/master/plugins/field-grid-dropdown/readme-media/dropdown.png) + +![](https://github.com/RaspberryPiFoundation/blockly-samples/raw/master/plugins/field-grid-dropdown/readme-media/dropdown-images.png) + +## Installation + +### Yarn + +``` +yarn add @blockly/field-grid-dropdown +``` + +### npm + +``` +npm install @blockly/field-grid-dropdown --save +``` + +## Usage + +This field accepts the same parameters as the [Blockly.FieldDropdown](https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creation) +in Blockly core. The config object bag passed into this field accepts additional optional parameters: + +- `"columns"` to specify the number of columns in the dropdown field (must be an integer greater than 0). + If not provided, the default is 3 columns. +- `"primaryColour"` to specify the colour of the dropdown (must be a string CSS colour). If not provided, + the dropdown color will match the primary colour of the parent block. +- `"borderColour"` to specify the colour of the border of the dropdown (must be a string CSS colour). If + not provided, the border colour will match the tertiary colour of the parent block. + +### JavaScript + +```js +import * as Blockly from 'blockly'; +import {FieldGridDropdown} from '@blockly/field-grid-dropdown'; +Blockly.Blocks['test_field_grid_dropdown'] = { + init: function () { + this.appendDummyInput() + .appendField('grid dropdown: ') + .appendField( + new FieldGridDropdown([ + ['A', 'A'], + ['B', 'B'], + ['C', 'C'], + ['D', 'D'], + ['E', 'E'], + ['F', 'F'], + ['G', 'G'], + ['H', 'H'], + ]), + 'FIELDNAME', + ); + }, +}; +``` + +### JSON + +```js +import * as Blockly from 'blockly'; +import '@blockly/field-grid-dropdown'; +Blockly.defineBlocksWithJsonArray([ + { + type: 'test_field_grid_dropdown', + message0: 'template: %1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + options: [ + ['A', 'A'], + ['B', 'B'], + ['C', 'C'], + ['D', 'D'], + ['E', 'E'], + ['F', 'F'], + ['G', 'G'], + ['H', 'H'], + ], + }, + ], + }, +]); +``` + +## License + +Apache 2.0 diff --git a/packages/plugins/field-grid-dropdown/package.json b/packages/plugins/field-grid-dropdown/package.json new file mode 100644 index 00000000000..4c8bd32886c --- /dev/null +++ b/packages/plugins/field-grid-dropdown/package.json @@ -0,0 +1,52 @@ +{ + "name": "@blockly/field-grid-dropdown", + "version": "13.1.0", + "description": "A Blockly dropdown field with grid layout.", + "scripts": { + "build": "blockly-scripts build", + "clean": "blockly-scripts clean", + "lint": "eslint .", + "predeploy": "npm run build && blockly-scripts predeploy", + "start": "blockly-scripts start", + "test": "blockly-scripts test" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.js", + "author": "Blockly Team", + "keywords": [ + "blockly", + "blockly-plugin", + "blockly-field", + "grid-dropdown" + ], + "homepage": "https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/field-grid-dropdown#readme", + "bugs": { + "url": "https://github.com/RaspberryPiFoundation/blockly/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaspberryPiFoundation/blockly.git", + "directory": "packages/plugins/field-grid-dropdown" + }, + "license": "Apache 2.0", + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src" + ], + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/field-grid-dropdown/readme-media/dropdown-images.png b/packages/plugins/field-grid-dropdown/readme-media/dropdown-images.png new file mode 100644 index 00000000000..c5d091dd1ee Binary files /dev/null and b/packages/plugins/field-grid-dropdown/readme-media/dropdown-images.png differ diff --git a/packages/plugins/field-grid-dropdown/readme-media/dropdown.png b/packages/plugins/field-grid-dropdown/readme-media/dropdown.png new file mode 100644 index 00000000000..62fad4e368e Binary files /dev/null and b/packages/plugins/field-grid-dropdown/readme-media/dropdown.png differ diff --git a/packages/plugins/field-grid-dropdown/src/grid.ts b/packages/plugins/field-grid-dropdown/src/grid.ts new file mode 100644 index 00000000000..3266c611394 --- /dev/null +++ b/packages/plugins/field-grid-dropdown/src/grid.ts @@ -0,0 +1,391 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + browserEvents, + common, + FieldDropdown, + ImageProperties, + MenuOption, + utils, + WorkspaceSvg, +} from 'blockly/core'; +import {GridItem} from './grid_item'; + +/** + * Class for managing a group of items displayed in a grid. + */ +export class Grid { + /** Mapping from grid item ID to index in the items list. */ + private itemIndices = new Map(); + + /** List of items displayed in this grid. */ + private items = new Array(); + + /** Root DOM element of this grid. */ + private root: HTMLDivElement; + + /** Identifier for keydown handler to be unregistered in dispose(). */ + private keyDownHandler: browserEvents.Data | null = null; + + /** Identifier for pointermove handler to be unregistered in dispose(). */ + private pointerMoveHandler: browserEvents.Data | null = null; + + /** Function to be called when an item in this grid is selected. */ + private selectionCallback?: (selectedItem: GridItem) => void; + + /** + * Creates a new Grid instance. + * + * @param container The parent element of this grid in the DOM. + * @param options A list of MenuOption objects representing the items to be + * shown in this grid. + * @param columns The number of columns to display items in. + * @param rtl True if this grid is being shown in a right-to-left environment. + * @param selectionCallback Function to be called when an item in the grid is + * selected. + */ + constructor( + container: HTMLElement, + options: MenuOption[], + private readonly columns: number, + private readonly rtl: boolean, + selectionCallback: (selectedItem: GridItem) => void, + ) { + this.selectionCallback = selectionCallback; + + this.root = document.createElement('div'); + this.root.className = 'blocklyFieldGrid'; + this.root.tabIndex = 0; + utils.aria.setRole(this.root, utils.aria.Role.GRID); + container.appendChild(this.root); + + this.populateItems(options); + + this.keyDownHandler = browserEvents.conditionalBind( + this.root, + 'keydown', + this, + this.onKeyDown, + ); + + this.pointerMoveHandler = browserEvents.conditionalBind( + this.root, + 'pointermove', + this, + this.onPointerMove, + true, + ); + + if (columns >= 1) { + this.columns = columns; + this.root.style.setProperty('--grid-columns', `${this.columns}`); + } else { + throw new Error(`Number of columns must be >= 1; got ${columns}`); + } + } + + /** + * Creates grid items in the DOM given a list of model objects. + * + * @param options A list of grid item model objects. + */ + private populateItems(options: MenuOption[]) { + let row = document.createElement('div'); + for (const [index, item] of options.entries()) { + // TODO(#2507): Don't just ignore separators. + if (item === FieldDropdown.SEPARATOR) continue; + + if (index % this.columns === 0) { + row = document.createElement('div'); + row.className = 'blocklyFieldGridRow'; + utils.aria.setRole(row, utils.aria.Role.ROW); + this.root.appendChild(row); + } + + const [label, value, ariaLabel] = item; + const content = (() => { + if (isImageProperties(label)) { + // Convert ImageProperties to an HTMLImageElement. + const image = new Image(label.width, label.height); + image.src = label.src; + image.alt = label.alt || ''; + return image; + } + return label; + })(); + + const gridItem = new GridItem( + row, + content, + value, + ariaLabel, + (selectedItem: GridItem) => { + this.setSelectedValue(selectedItem.getValue()); + this.selectionCallback?.(selectedItem); + }, + ); + this.itemIndices.set(gridItem.getId(), this.itemIndices.size); + this.items.push(gridItem); + } + } + + /** + * Disposes of this grid. + */ + dispose() { + this.selectionCallback = undefined; + for (const item of this.items) { + item.dispose(); + } + this.itemIndices.clear(); + this.items.length = 0; + if (this.keyDownHandler) { + browserEvents.unbind(this.keyDownHandler); + this.keyDownHandler = null; + } + + if (this.pointerMoveHandler) { + browserEvents.unbind(this.pointerMoveHandler); + this.pointerMoveHandler = null; + } + this.root.remove(); + } + + /** + * Handles a keydown event in the grid, generally by moving focus. + * + * @param e The keydown event to handle. + */ + private onKeyDown(e: KeyboardEvent) { + if ( + !this.items.length || + e.shiftKey || + e.ctrlKey || + e.metaKey || + e.altKey + ) { + return; + } + + switch (e.key) { + case 'ArrowUp': + this.moveFocus(-1, true, false); + break; + case 'ArrowDown': + this.moveFocus(1, true, false); + break; + case 'ArrowLeft': + this.moveFocus(-1 * (this.rtl ? -1 : 1), true, true); + break; + case 'ArrowRight': + this.moveFocus(1 * (this.rtl ? -1 : 1), true, true); + break; + case 'PageUp': + case 'Home': + this.moveFocus(0, false, true); + break; + case 'PageDown': + case 'End': + this.moveFocus(this.items.length - 1, false, true); + break; + case 'Enter': + case 'Space': + // Handled via GridItem click handler, so we want its default but it + // must not propagate. + e.stopPropagation(); + return; + default: + // Not a key the grid is interested in. + return; + } + // The grid used this key, don't let it have secondary effects. + e.preventDefault(); + e.stopPropagation(); + } + + /** + * Handles a pointermove event in the grid by focusing the hovered item. + * + * @param e The pointermove event to handle. + */ + private onPointerMove(e: PointerEvent) { + // Don't highlight grid items on "pointermove" if the pointer didn't + // actually move (but the content under it did due to e.g. scrolling into + // view), or if the target isn't an Element, which should never happen, but + // TS needs to be reassured of that. + if (!(e.movementX || e.movementY) || !(e.target instanceof Element)) return; + + const gridItem = e.target.closest('.blocklyFieldGridItem'); + if (!gridItem) return; + + const targetId = gridItem.id; + const targetIndex = this.itemIndices.get(targetId); + if (targetIndex === undefined) return; + this.moveFocus(targetIndex, false, true); + } + + /** + * Selects the item with the given value in the grid. + * + * @param value The value of the grid item to select. + */ + setSelectedValue(value: string) { + for (const [index, item] of this.items.entries()) { + const selected = item.getValue() === value; + item.setSelected(selected); + if (selected) { + this.moveFocus(index, false, true); + } + } + } + + /** + * Moves browser focus to the grid item at the given index. + * + * @param movementIndex The absolute or directionally relative index of the + * item to focus. + * @param relative True to interpret the index as relative to the currently + * focused item, false to move focus to it as an absolute value. + * @param horizontal True for a left/right move. False for an up/down move. + * Vertical moves wrap to the next/previous column at column edges, but + * do not wrap around the ends of the grid. + */ + private moveFocus( + movementIndex: number, + relative: boolean, + horizontal: boolean, + ) { + let targetIndex = movementIndex; + + if (relative) { + const focusedItem = this.getFocusedItem(); + if (!focusedItem) return; + const currentIndex = this.indexOfItem(focusedItem); + + if (horizontal) { + targetIndex += currentIndex; + } else { + targetIndex = this.getVerticalTargetIndex(currentIndex, movementIndex); + } + } + + const targetItem = this.itemAtIndex(targetIndex); + if (!targetItem) { + const workspace = common.getMainWorkspace(); + if (workspace instanceof WorkspaceSvg) { + workspace.getAudioManager().playErrorBeep(); + } + return; + } + + targetItem.focus(); + utils.aria.setState( + this.root, + utils.aria.State.ACTIVEDESCENDANT, + targetItem.getId(), + ); + } + + /** + * Returns the index to focus after a vertical move from the given index. + * + * Prefers moving within the same column. At a column edge, wraps to the + * first/last item of the adjacent column. Returns an out-of-bounds index + * when movement is blocked at the start or end of the grid. + * + * @param currentIndex The currently focused item index. + * @param direction 1 to move down, -1 to move up. + * @returns The target item index. + */ + private getVerticalTargetIndex( + currentIndex: number, + direction: number, + ): number { + // First try moving to the vertically adjacent item in the same column. + const column = currentIndex % this.columns; + const row = Math.floor(currentIndex / this.columns); + const targetIndex = (row + direction) * this.columns + column; + + if (this.itemAtIndex(targetIndex)) { + return targetIndex; + } + + // We are past this column's edge, so wrap to the adjacent column. + const targetColumn = column + direction; + if (targetColumn < 0 || targetColumn >= this.columns) { + return targetIndex; + } + + const columnItems = this.items.filter( + (item, i) => i % this.columns === targetColumn, + ); + const item = + direction > 0 ? columnItems[0] : columnItems[columnItems.length - 1]; + return this.indexOfItem(item); + } + + /** + * Returns the index of the given item within the grid. + * + * @param item The item to return the index of. + * @returns The index of the given item within the grid. + */ + private indexOfItem(item: GridItem): number { + return this.itemIndices.get(item.getId()) ?? -1; + } + + /** + * Returns the GridItem object at the given index in the grid. + * + * @param index The index to retrieve the grid item at. + * @returns The GridItem at the given index, or undefined if the index is + * invalid. + */ + private itemAtIndex(index: number): GridItem | undefined { + return this.items[index]; + } + + /** + * Returns the currently focused grid item, if any. + * + * @returns The focused grid item, or undefined if no item is focused. + */ + private getFocusedItem(): GridItem | undefined { + const element = + this.root.querySelector('.blocklyFieldGridItem:focus') ?? + this.root.querySelector('.blocklyFieldGridItem'); + if (!element || !element.id) return undefined; + + const index = this.itemIndices.get(element.id); + if (index === undefined) return undefined; + + return this.itemAtIndex(index); + } +} + +/** + * Returns whether or not an object conforms to the ImageProperties + * interface. + * + * @param obj The object to test. + * @returns True iff the object conforms to ImageProperties. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isImageProperties(obj: any): obj is ImageProperties { + return ( + obj && + typeof obj === 'object' && + 'src' in obj && + typeof obj.src === 'string' && + 'alt' in obj && + typeof obj.alt === 'string' && + 'width' in obj && + typeof obj.width === 'number' && + 'height' in obj && + typeof obj.height === 'number' + ); +} diff --git a/packages/plugins/field-grid-dropdown/src/grid_item.ts b/packages/plugins/field-grid-dropdown/src/grid_item.ts new file mode 100644 index 00000000000..3d78842b582 --- /dev/null +++ b/packages/plugins/field-grid-dropdown/src/grid_item.ts @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {browserEvents, utils} from 'blockly/core'; + +/** + * Class representing an item in a grid. + */ +export class GridItem { + /** The DOM element for the grid item. */ + private element: HTMLButtonElement; + + /** Identifier for a click handler to unregister during dispose(). */ + private clickHandler: browserEvents.Data | null; + + /** Callback to invoke when this item is selected. */ + private selectionCallback: ((selectedItem: GridItem) => void) | null; + + /** Whether or not this item is currently selected. */ + private selected = false; + + /** + * Creates a new GridItem. + * + * @param container The parent element of this grid item in the DOM. + * @param content The content to display in this grid item. + * @param value The programmatic value of this grid item. + * @param ariaLabel An accessibility label that describes the contents of this + * grid item to screenreaders. + * @param selectionCallback Function to call when this item is selected. + */ + constructor( + container: HTMLElement, + content: string | HTMLElement, + private readonly value: string, + ariaLabel: string | undefined, + selectionCallback: (selectedItem: GridItem) => void, + ) { + this.selectionCallback = selectionCallback; + + const cell = document.createElement('div'); + utils.aria.setRole(cell, utils.aria.Role.GRIDCELL); + + this.element = document.createElement('button'); + this.element.id = utils.idGenerator.getNextUniqueId(); + this.element.className = 'blocklyFieldGridItem'; + this.clickHandler = browserEvents.conditionalBind( + this.element, + 'click', + this, + this.onClick, + true, + ); + cell.appendChild(this.element); + container.appendChild(cell); + + const contentDom = + typeof content === 'string' ? document.createTextNode(content) : content; + this.element.appendChild(contentDom); + + if (ariaLabel) { + utils.aria.setState(this.element, utils.aria.State.LABEL, ariaLabel); + } + } + + /** + * Disposes of this grid item. + */ + dispose() { + this.selectionCallback = null; + this.element.remove(); + if (this.clickHandler) { + browserEvents.unbind(this.clickHandler); + this.clickHandler = null; + } + } + + /** + * Gets the unique (within the grid) ID for this grid item. + * + * @returns This item's unique ID. + */ + getId(): string { + return this.element.id; + } + + /** + * Gets the value associated with this grid item. + * + * @returns Value associated with this grid item. + */ + getValue(): string { + return this.value; + } + + /** + * Returns whether or not this grid item is selected. + * + * @returns True if this grid item is selected, otherwise false. + */ + isSelected() { + return this.selected; + } + + /** + * Sets whether or not this grid item is selected. + * + * @param selected True if this grid item should be selected, otherwise false. + */ + setSelected(selected: boolean) { + this.selected = selected; + utils.aria.setState(this.element, utils.aria.State.SELECTED, this.selected); + this.element.classList.toggle( + 'blocklyFieldGridItemSelected', + this.selected, + ); + if (this.isSelected()) { + this.focus(); + } + } + + /** + * Handles clicks on this grid item by marking it as selected. + */ + private onClick() { + this.setSelected(true); + this.selectionCallback?.(this); + } + + /** + * Makes this grid item the browser focus target, and scrolls it into view + * if needed. + */ + focus() { + // Focus the element, but don't scroll the document since that's too + // aggressive. + this.element.focus({preventScroll: true}); + + const scrollingParent = this.element.offsetParent; + if (!scrollingParent) return; + const offsetTop = this.element.offsetTop; + const scrollTop = scrollingParent.scrollTop; + const spacing = this.getInterItemSpacing(); + + // Scroll the element into view if it's offscreen above the grid's viewport. + if (offsetTop < scrollTop) { + scrollingParent.scrollTo(0, offsetTop - spacing); + } else if ( + offsetTop + this.element.offsetHeight > + scrollTop + scrollingParent.clientHeight + ) { + // Scroll into view if this item is below the grid's viewport. + scrollingParent.scrollBy( + 0, + offsetTop + + this.element.clientHeight - + (scrollTop + scrollingParent.clientHeight) + + spacing, + ); + } + } + + /** + * Returns the vertical spacing between grid items in pixels. + * + * This value can be specified by the user in CSS, so we can't just use a + * hardcoded value. Moreover, while we could check our computed style, the + * grid gap can be specified in several units. Instead, this somewhat hackily + * finds all the sibling items in this grid and loops through them until it + * encounters one with a different vertical location from its predecessor, + * then computes the effective gap based on their relative position and + * height. + * + * @returns The vertical distance between items in this grid. + */ + private getInterItemSpacing() { + const grid = this.element.closest('.blocklyFieldGrid'); + if (!grid) return 0; + + const items = [ + ...grid.querySelectorAll('.blocklyFieldGridItem'), + ] as HTMLElement[]; + if (!items.length) return 0; + + const initialTop = items[0].offsetTop; + const initialHeight = items[0].offsetHeight; + for (const item of items) { + if (item.offsetTop !== initialTop) { + return item.offsetTop - initialHeight - initialTop; + } + } + + return 0; + } +} diff --git a/packages/plugins/field-grid-dropdown/src/index.ts b/packages/plugins/field-grid-dropdown/src/index.ts new file mode 100644 index 00000000000..f0c88104569 --- /dev/null +++ b/packages/plugins/field-grid-dropdown/src/index.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Grid dropdown field. + * @author kozbial@google.com (Monica Kozbial) + */ + +import * as Blockly from 'blockly/core'; +import {Grid} from './grid'; +import type {GridItem} from './grid_item'; + +/** + * A config object for defining a field grid dropdown. + */ +export interface FieldGridDropdownConfig extends Blockly.FieldDropdownConfig { + columns?: string | number; + primaryColour?: string; + borderColour?: string; +} + +/** + * Construct a FieldGridDropdown from a JSON arg object. + */ +export interface FieldGridDropdownFromJsonConfig extends FieldGridDropdownConfig { + options?: Blockly.MenuGenerator; +} + +type FieldGridDropdownValidator = Blockly.FieldDropdownValidator; + +/** + * Grid dropdown field. + */ +export class FieldGridDropdown extends Blockly.FieldDropdown { + /** + * The number of columns in the dropdown grid. Must be an integer value + * greater than 0. Defaults to 3. + */ + private columns = 3; + + private primaryColour?: string; + + private borderColour?: string; + + /** Object representing the grid of choices shown in the dropdown. */ + private grid: Grid | null = null; + + protected override ariaTypeName = Blockly.Msg['ARIA_TYPE_FIELD_GRID']; + + /** + * Class for an grid dropdown field. + * + * @param menuGenerator A non-empty array of options for a dropdown list, + * or a function which generates these options. + * @param validator A function that is called to validate + * changes to the field's value. Takes in a language-neutral dropdown + * option & returns a validated language-neutral dropdown option, or null + * to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/dropdown#creation} + * for a list of properties this parameter supports. + * @extends {Blockly.Field} + * @constructor + * @throws {TypeError} If `menuGenerator` options are incorrectly structured. + */ + constructor( + menuGenerator: Blockly.MenuGenerator, + validator?: FieldGridDropdownValidator, + config?: FieldGridDropdownConfig, + ) { + super(menuGenerator, validator, config); + + if (config?.columns) { + this.setColumns(parseInt(`${config.columns}`)); + } + + if (config && config.primaryColour) { + this.primaryColour = config.primaryColour; + } + + if (config && config.borderColour) { + this.borderColour = config.borderColour; + } + } + + /** + * Constructs a FieldGridDropdown from a JSON arg object. + * + * @param config A JSON object with options. + * @returns The new field instance. + * @package + * @nocollapse + */ + static fromJson(config: FieldGridDropdownFromJsonConfig) { + if (!config.options) { + throw new Error( + 'options are required for the dropdown field. The ' + + 'options property must be assigned an array of ' + + '[humanReadableValue, languageNeutralValue] tuples.', + ); + } + // `this` might be a subclass of FieldDropdown if that class doesn't + // override the static fromJson method. + return new this(config.options, undefined, config); + } + + /** + * Sets the number of columns on the grid. Updates the styling to reflect. + * + * @param columns The number of columns. Is rounded to + * an integer value and must be greater than 0. Invalid + * values are ignored. + */ + setColumns(columns: number) { + if (!isNaN(columns) && columns >= 1) { + this.columns = columns; + // If the field is currently being shown, reload the grid. + if ( + Blockly.DropDownDiv.getOwner() === this && + Blockly.DropDownDiv.isVisible() + ) { + this.grid?.dispose(); + this.showEditor_(); + } + } + } + + /** + * Updates the ARIA roles and label for this field. + */ + override recomputeAriaContext(): boolean { + const shouldCustomize = super.recomputeAriaContext(); + if (!shouldCustomize) return false; + const focusableElement = this.getFocusableElement(); + Blockly.utils.aria.setState( + focusableElement, + Blockly.utils.aria.State.HASPOPUP, + 'grid', + ); + Blockly.utils.aria.setState( + focusableElement, + Blockly.utils.aria.State.EXPANDED, + !!this.grid, + ); + return true; + } + + /* eslint-disable @typescript-eslint/naming-convention */ + /** + * Create a dropdown menu under the text. + * + * @param e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + */ + protected showEditor_(e?: MouseEvent) { + Blockly.DropDownDiv.clearContent(); + const rtl = !!this.getSourceBlock()?.workspace.RTL; + this.grid = new Grid( + Blockly.DropDownDiv.getContentDiv(), + this.getOptions(false), + this.columns, + rtl, + (selectedItem: GridItem) => { + Blockly.DropDownDiv.hideIfOwner(this); + this.setValue(selectedItem.getValue()); + }, + ); + + Blockly.DropDownDiv.getContentDiv().classList.add( + 'blocklyFieldGridContainer', + ); + + const colours = this.getColours(); + if (colours && colours.border) { + Blockly.DropDownDiv.setColour(colours.primary, colours.border); + } + + Blockly.DropDownDiv.showPositionedByField( + this, + this.dropdownDispose_.bind(this), + ); + + const selectedValue = this.getValue(); + if (selectedValue) { + this.grid.setSelectedValue(selectedValue); + } + this.recomputeAriaContext(); + } + + /** + * Disposes of events and DOM-references belonging to the dropdown editor. + */ + protected override dropdownDispose_() { + // Keep aria-expanded accurate on later recomputes. + this.grid = null; + super.dropdownDispose_(); + this.recomputeAriaContext(); + } + + /** + * Updates the field's value to the given value. + * + * @param newValue The new value for this field. + */ + protected override doValueUpdate_(newValue: string) { + super.doValueUpdate_(newValue); + this.grid?.setSelectedValue(newValue); + } + + /** + * Determine the colours for the dropdowndiv. The dropdown should match block + * colour unless other colours are specified in the config. + * + * @returns The colours to set for the dropdowndiv. + */ + private getColours() { + if (this.primaryColour && this.borderColour) { + return { + primary: this.primaryColour, + border: this.borderColour, + }; + } + + const sourceBlock = this.getSourceBlock(); + if (!(sourceBlock instanceof Blockly.BlockSvg)) return; + + const colourSource = sourceBlock.isShadow() + ? sourceBlock.getParent() + : sourceBlock; + if (!colourSource) return; + + return { + primary: this.primaryColour ?? colourSource.getColour(), + border: this.borderColour ?? colourSource.getColourTertiary(), + }; + } +} + +Blockly.fieldRegistry.register('field_grid_dropdown', FieldGridDropdown); + +/** + * CSS for grid field. + */ +Blockly.Css.register(` + .blocklyFieldGridContainer { + padding: 7px; + overflow: auto; + } + + .blocklyFieldGrid { + display: grid; + grid-gap: 7px; + grid-template-columns: repeat(var(--grid-columns), min-content); + } + + .blocklyFieldGrid .blocklyFieldGridItem { + border: 1px solid rgba(1, 1, 1, 0.5); + border-radius: 4px; + color: white; + min-width: auto; + background: none; + white-space: nowrap; + cursor: pointer; + padding: 6px 15px; + } + + .blocklyFieldGrid .blocklyFieldGridRow { + display: contents; + } + + .blocklyFieldGrid .blocklyFieldGridItem.blocklyFieldGridItemSelected { + background-color: rgba(1, 1, 1, 0.25); + } + + .blocklyFieldGrid .blocklyFieldGridItem:focus { + box-shadow: 0 0 0 4px hsla(0, 0%, 100%, .2); + outline: none; + } + `); diff --git a/packages/plugins/field-grid-dropdown/test/field_test.mocha.js b/packages/plugins/field-grid-dropdown/test/field_test.mocha.js new file mode 100644 index 00000000000..6ba8fa1bea2 --- /dev/null +++ b/packages/plugins/field-grid-dropdown/test/field_test.mocha.js @@ -0,0 +1,218 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const {testHelpers} = require('@blockly/dev-tools'); +const {FieldGridDropdown} = require('../src/index'); + +const { + assertFieldValue, + FieldCreationTestCase, + FieldValueTestCase, + runConstructorSuiteTests, + runFromJsonSuiteTests, + runSetValueTests, +} = testHelpers; + +suite('FieldGridDropdown', function () { + /** + * Configuration for field tests with invalid values. + * @type {Array} + */ + const invalidValueCreationTestCases = [ + {title: 'Undefined', args: [undefined]}, + {title: 'Array Items not Arrays', args: [undefined]}, + { + title: 'Array Items with Invalid IDs', + args: [ + [ + ['1', 1], + ['2', 2], + ['3', 3], + ], + ], + }, + { + title: 'Array Items with Invalid Content', + args: [ + [ + [1, '1'], + [2, '2'], + [3, '3'], + ], + ], + }, + ]; + /** + * Configuration for field tests with valid values. + * @type {Array} + */ + const validValueCreationTestCases = [ + { + title: 'Text Dropdown', + value: 'A', + expectedValue: 'A', + expectedText: 'a', + args: [ + [ + ['a', 'A'], + ['b', 'B'], + ['c', 'C'], + ], + ], + }, + { + title: 'Image Dropdown', + value: 'A', + expectedValue: 'A', + expectedText: 'a', + args: [ + [ + [{src: 'scrA', alt: 'a', width: 0, height: 0}, 'A'], + [{src: 'scrB', alt: 'b', width: 0, height: 0}, 'B'], + [{src: 'scrC', alt: 'c', width: 0, height: 0}, 'C'], + ], + ], + }, + { + title: 'Dynamic Text Dropdown', + value: 'A', + expectedValue: 'A', + expectedText: 'a', + args: [ + () => { + return [ + ['a', 'A'], + ['b', 'B'], + ['c', 'C'], + ]; + }, + ], + }, + { + title: 'Dynamic Image Dropdown', + value: 'A', + expectedValue: 'A', + expectedText: 'a', + args: [ + () => { + return [ + [{src: 'scrA', alt: 'a', width: 0, height: 0}, 'A'], + [{src: 'scrB', alt: 'b', width: 0, height: 0}, 'B'], + [{src: 'scrC', alt: 'c', width: 0, height: 0}, 'C'], + ]; + }, + ], + }, + ]; + const addJson = function (testCase) { + testCase.json = {options: testCase.args[0]}; + }; + invalidValueCreationTestCases.forEach(addJson); + validValueCreationTestCases.forEach(addJson); + + /** + * Asserts that the field properties are correct based on the test case. + * @param {!FieldGridDropdown} field The field to check. + * @param {!FieldValueTestCase} testCase The test case. + */ + const validTestCaseAssertField = function (field, testCase) { + assertFieldValue(field, testCase.expectedValue, testCase.expectedText); + }; + + runConstructorSuiteTests( + FieldGridDropdown, + validValueCreationTestCases, + invalidValueCreationTestCases, + validTestCaseAssertField, + ); + + runFromJsonSuiteTests( + FieldGridDropdown, + validValueCreationTestCases, + invalidValueCreationTestCases, + validTestCaseAssertField, + ); + + /** + * Configuration for field tests with invalid values. + * @type {!Array} + */ + const invalidValueSetValueTestCases = [ + {title: 'Null', value: null}, + {title: 'Undefined', value: undefined}, + {title: 'Invalid ID', value: 'bad'}, + ]; + /** + * Configuration for field tests with valid values. + * @type {!Array} + */ + const validValueSetValueTestCases = [ + {title: 'Valid ID', value: 'B', expectedValue: 'B', expectedText: 'b'}, + ]; + + suite('setValue', function () { + setup(function () { + this.field = new FieldGridDropdown([ + ['a', 'A'], + ['b', 'B'], + ['c', 'C'], + ]); + }); + runSetValueTests( + validValueSetValueTestCases, + invalidValueSetValueTestCases, + 'A', + 'a', + ); + }); + + suite('Validators', function () { + setup(function () { + this.dropdownField = new FieldGridDropdown([ + ['1a', '1A'], + ['1b', '1B'], + ['1c', '1C'], + ['2a', '2A'], + ['2b', '2B'], + ['2c', '2C'], + ]); + }); + teardown(function () { + this.dropdownField.setValidator(null); + }); + suite('Null Validator', function () { + setup(function () { + this.dropdownField.setValidator(function () { + return null; + }); + }); + test('New Value', function () { + this.dropdownField.setValue('1B'); + assertFieldValue(this.dropdownField, '1A', '1a'); + }); + }); + suite('Force 1s Validator', function () { + setup(function () { + this.dropdownField.setValidator(function (newValue) { + return '1' + newValue.charAt(1); + }); + }); + test('New Value', function () { + this.dropdownField.setValue('2B'); + assertFieldValue(this.dropdownField, '1B', '1b'); + }); + }); + suite('Returns Undefined Validator', function () { + setup(function () { + this.dropdownField.setValidator(function () {}); + }); + test('New Value', function () { + this.dropdownField.setValue('1B'); + assertFieldValue(this.dropdownField, '1B', '1b'); + }); + }); + }); +}); diff --git a/packages/plugins/field-grid-dropdown/test/index.html b/packages/plugins/field-grid-dropdown/test/index.html new file mode 100644 index 00000000000..10cd0a0c1da --- /dev/null +++ b/packages/plugins/field-grid-dropdown/test/index.html @@ -0,0 +1,18 @@ + + + + + Blockly Field Test + + + + +
+ + + diff --git a/packages/plugins/field-grid-dropdown/test/index.ts b/packages/plugins/field-grid-dropdown/test/index.ts new file mode 100644 index 00000000000..9ccc35fd8af --- /dev/null +++ b/packages/plugins/field-grid-dropdown/test/index.ts @@ -0,0 +1,468 @@ +/** + * @license + * Copyright 2020 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Field test. + */ + +import * as Blockly from 'blockly'; +import {createPlayground} from '@blockly/dev-tools'; +import '../src/index'; + +/** + * An array of blocks that are defined only for the purposes of + * manually and visually testing the dropdown grid field. + */ +const testBlockDefinitions = [ + { + type: 'test_standard_field_values', + message0: '%1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + options: [['A', 'A']], + alt: { + type: 'field_label', + text: `No field_grid_dropdown`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values', + message0: 'Different text length %1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + options: [ + ['A', 'A'], + ['long text ', 'long text'], + ['B', 'B'], + ['C', 'C'], + [ + 'really really really loooooong text', + 'really really really loooooong text', + ], + ['D', 'D'], + ['E', 'E'], + ], + alt: { + type: 'field_label', + text: `No field_grid_dropdown`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_long_text_list', + message0: 'Long text list %1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + options: [ + ['A', 'A'], + ['B', 'B'], + ['C', 'C'], + ['D', 'D'], + ['E', 'E'], + ['D', 'F'], + ['G', 'G'], + ['H', 'H'], + ['I', 'I'], + ['J', 'J'], + ['K', 'K'], + ['L', 'L'], + ['M', 'M'], + ['N', 'N'], + ['O', 'O'], + ['P', 'P'], + ['Q', 'Q'], + ['R', 'R'], + ['S', 'S'], + ['T', 'T'], + ['U', 'U'], + ['V', 'V'], + ['W', 'W'], + ['X', 'X'], + ['Y', 'Y'], + ['Z', 'Z'], + ], + alt: { + type: 'field_label', + text: `No field_grid_dropdown`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_images', + message0: 'Images %1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + options: [ + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/a.png', + width: 32, + height: 32, + alt: 'A', + }, + 'A', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/b.png', + width: 32, + height: 32, + alt: 'B', + }, + 'B', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/c.png', + width: 32, + height: 32, + alt: 'C', + }, + 'C', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/d.png', + width: 32, + height: 32, + alt: 'D', + }, + 'D', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/e.png', + width: 32, + height: 32, + alt: 'E', + }, + 'E', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/f.png', + width: 32, + height: 32, + alt: 'F', + }, + 'F', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/g.png', + width: 32, + height: 32, + alt: 'G', + }, + 'G', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/h.png', + width: 32, + height: 32, + alt: 'H', + }, + 'H', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/i.png', + width: 32, + height: 32, + alt: 'I', + }, + 'I', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/j.png', + width: 32, + height: 32, + alt: 'J', + }, + 'J', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/k.png', + width: 32, + height: 32, + alt: 'K', + }, + 'K', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/l.png', + width: 32, + height: 32, + alt: 'L', + }, + 'L', + ], + [ + { + src: 'https://blockly-demo.appspot.com/static/tests/media/m.png', + width: 32, + height: 32, + alt: 'M', + }, + 'M', + ], + ], + alt: { + type: 'field_label', + text: `No field_grid_dropdown`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_4_columns', + message0: '4 columns %1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + columns: '4', + options: [ + ['A', 'A'], + ['B', 'B'], + ['C', 'C'], + ['D', 'D'], + ['E', 'E'], + ['F', 'F'], + ['G', 'G'], + ['H', 'H'], + ], + alt: { + type: 'field_label', + text: `No field_grid_dropdown`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_custom_colours', + message0: 'Custom colours %1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + primaryColour: '#783105', + borderColour: '#d6a587', + options: [ + ['A', 'A'], + ['B', 'B'], + ['C', 'C'], + ['D', 'D'], + ['E', 'E'], + ['F', 'F'], + ['G', 'G'], + ['H', 'H'], + ], + alt: { + type: 'field_label', + text: `No field_grid_dropdown`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_standard_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + options: [['A', 'A']], + alt: { + type: 'field_label', + text: `No field_grid_dropdown`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_custom_field_values_and_label', + message0: 'block %1', + args0: [ + { + type: 'field_grid_dropdown', + name: 'FIELDNAME', + options: [['A', 'A']], + alt: { + type: 'field_label', + text: `No field_grid_dropdown`, + }, + }, + ], + output: null, + style: 'math_blocks', + }, + { + type: 'test_parent_block', + message0: 'parent %1', + args0: [ + { + type: 'input_value', + name: 'INPUT', + }, + ], + previousStatement: null, + nextStatement: null, + style: 'loop_blocks', + }, +]; + +Blockly.defineBlocksWithJsonArray(testBlockDefinitions); + +/** + * A test toolbox containing the exported blocks and a variety of + * test blocks to exercise the dropdown grid field in different contexts + * (on a shadow block, as the only field on a block, etc). + * These are in a simple toolbox, rather than a category toolbox, so that + * they are all instantiated every time the test page is opened. + */ +const jsonToolbox = { + contents: [ + { + kind: 'label', + text: 'Test blocks: default field values', + }, + { + kind: 'block', + type: 'test_standard_field_values', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_standard_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_standard_field_values_and_label', + }, + }, + }, + }, + { + kind: 'label', + text: 'Test blocks: custom field values', + }, + { + kind: 'block', + type: 'test_custom_field_values', + }, + { + kind: 'block', + type: 'test_custom_field_values_long_text_list', + }, + { + kind: 'block', + type: 'test_custom_field_values_images', + }, + { + kind: 'block', + type: 'test_custom_field_values_4_columns', + }, + { + kind: 'block', + type: 'test_custom_field_values_custom_colours', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values', + }, + }, + }, + }, + { + kind: 'block', + type: 'test_custom_field_values_and_label', + }, + { + kind: 'block', + type: 'test_parent_block', + inputs: { + INPUT: { + shadow: { + type: 'test_custom_field_values_and_label', + }, + }, + }, + }, + ], +}; +/** + * Create a workspace. + * + * @param blocklyDiv The blockly container div. + * @param options The Blockly options. + * @returns The created workspace. + */ +function createWorkspace( + blocklyDiv: HTMLElement, + options: Blockly.BlocklyOptions, +): Blockly.Workspace { + const workspace = Blockly.inject(blocklyDiv, options); + return workspace; +} + +document.addEventListener('DOMContentLoaded', function () { + const defaultOptions: Blockly.BlocklyOptions = { + toolbox: jsonToolbox, + }; + const rootElement = document.getElementById('root'); + if (rootElement) { + createPlayground(rootElement, createWorkspace, defaultOptions); + } +}); diff --git a/packages/plugins/field-grid-dropdown/tsconfig.json b/packages/plugins/field-grid-dropdown/tsconfig.json new file mode 100644 index 00000000000..99858c525f1 --- /dev/null +++ b/packages/plugins/field-grid-dropdown/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "rootDir": "./src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "es2015", + "moduleResolution": "bundler", + "target": "es6", + "strict": true + }, + // NOTE: `test/**/*` is automatically included in `blockly-scripts start`. + // Only src matters for production builds. + "include": ["src"] +} diff --git a/packages/plugins/field-multilineinput/CHANGELOG.md b/packages/plugins/field-multilineinput/CHANGELOG.md new file mode 100644 index 00000000000..2af0b4e971e --- /dev/null +++ b/packages/plugins/field-multilineinput/CHANGELOG.md @@ -0,0 +1,511 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [13.1.0](https://github.com/RaspberryPiFoundation/blockly-samples/compare/v13.0.0...v13.1.0) (2026-06-30) + +### ⚠ BREAKING CHANGES + +* make field-multilineinput keyboard navigable and accessible (#2729) +* Bump Blockly dependency to v13 (#2704) + +### Features + +* make field-multilineinput keyboard navigable and accessible ([#2729](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2729)) + +### Miscellaneous Chores + +* Bump Blockly dependency to v13 ([#2704](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2704)) + + +## [6.0.9](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-multilineinput@6.0.8...@blockly/field-multilineinput@6.0.9) (2026-04-09) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [6.0.8](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-multilineinput@6.0.7...@blockly/field-multilineinput@6.0.8) (2026-01-15) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [6.0.7](https://github.com/RaspberryPiFoundation/blockly-samples/compare/@blockly/field-multilineinput@6.0.6...@blockly/field-multilineinput@6.0.7) (2026-01-05) + + +### Bug Fixes + +* update plugin URLs to RPF ([#2665](https://github.com/RaspberryPiFoundation/blockly-samples/issues/2665)) ([6c34039](https://github.com/RaspberryPiFoundation/blockly-samples/commit/6c3403960b8070eb8e576bd5702bdb53a09ac475)) + + + +## [6.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@6.0.5...@blockly/field-multilineinput@6.0.6) (2026-01-05) + + +### Bug Fixes + +* Merge multiline text readme to with pre-v11 documentation ([#2660](https://github.com/google/blockly-samples/issues/2660)) ([c8d0bd3](https://github.com/google/blockly-samples/commit/c8d0bd3b37d7e6765e7761066c4033d422ec4470)) + + + +## [6.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@6.0.4...@blockly/field-multilineinput@6.0.5) (2025-12-19) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [6.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@6.0.3...@blockly/field-multilineinput@6.0.4) (2025-12-19) + + +### Bug Fixes + +* Use getTextWidth instead of getFastTextWidth ([#2632](https://github.com/google/blockly-samples/issues/2632)) ([affcd6e](https://github.com/google/blockly-samples/commit/affcd6ed28506c3640f3a77700640d38eec43841)) + + + +## [6.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@6.0.2...@blockly/field-multilineinput@6.0.3) (2025-10-02) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [6.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@6.0.1...@blockly/field-multilineinput@6.0.2) (2025-07-17) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [6.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@6.0.0...@blockly/field-multilineinput@6.0.1) (2025-05-22) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [6.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.17...@blockly/field-multilineinput@6.0.0) (2025-05-16) + + +### ⚠ BREAKING CHANGES + +* update all plugins to blockly v12 (#2538) + +### Features + +* update all plugins to blockly v12 ([#2538](https://github.com/google/blockly-samples/issues/2538)) ([420ba31](https://github.com/google/blockly-samples/commit/420ba3124b260b2c06e8e1d61be44e870780d95f)) + + + +## [5.0.17](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.16...@blockly/field-multilineinput@5.0.17) (2025-05-16) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.16](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.15...@blockly/field-multilineinput@5.0.16) (2025-05-15) + + +### Bug Fixes + +* Fix multi-line text color by using the correct class ([91a8473](https://github.com/google/blockly-samples/commit/91a8473736b5c0cc3e04c809f4a888469bc96f77)) + + +### Reverts + +* "fix(FieldMultilineInput): Use string literal instead of Field.NBSP" ([539ae4f](https://github.com/google/blockly-samples/commit/539ae4fe4499fca3d781c29f462836ac64d45ce6)) + + + + + +## [5.0.15](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.14...@blockly/field-multilineinput@5.0.15) (2025-02-13) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.13...@blockly/field-multilineinput@5.0.14) (2024-12-19) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.12...@blockly/field-multilineinput@5.0.13) (2024-12-03) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.11...@blockly/field-multilineinput@5.0.12) (2024-11-07) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.10...@blockly/field-multilineinput@5.0.11) (2024-09-26) + + +### Bug Fixes + +* **field-*:** Remove unneeded `fieldRegistry.unregister` calls ([#2454](https://github.com/google/blockly-samples/issues/2454)) ([b3ba30e](https://github.com/google/blockly-samples/commit/b3ba30e23dddf0bd98c266659aa229ba6ba685b0)), closes [#2453](https://github.com/google/blockly-samples/issues/2453) + + + + + +## [5.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.9...@blockly/field-multilineinput@5.0.10) (2024-08-29) + + +### Bug Fixes + +* predeploy plugins ([#2449](https://github.com/google/blockly-samples/issues/2449)) ([6b36d8b](https://github.com/google/blockly-samples/commit/6b36d8b344a969f79d89bbc7dcee29ae554759f9)) + + + + + +## [5.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.8...@blockly/field-multilineinput@5.0.9) (2024-08-22) + + +### Bug Fixes + +* lerna v8 breaking our whole shebangle ([#2446](https://github.com/google/blockly-samples/issues/2446)) ([b8b4c21](https://github.com/google/blockly-samples/commit/b8b4c21d4eaf81f527336ae46f6679ff99ac23c3)) + + + + + +## [5.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.7...@blockly/field-multilineinput@5.0.8) (2024-08-08) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.6...@blockly/field-multilineinput@5.0.7) (2024-08-01) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.5...@blockly/field-multilineinput@5.0.6) (2024-07-25) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.4...@blockly/field-multilineinput@5.0.5) (2024-07-11) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.3...@blockly/field-multilineinput@5.0.4) (2024-07-04) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.2...@blockly/field-multilineinput@5.0.3) (2024-06-27) + + +### Bug Fixes + +* **docs:** Update README for multiline field ([#2394](https://github.com/google/blockly-samples/issues/2394)) ([377d38c](https://github.com/google/blockly-samples/commit/377d38c919723861aa979bdcf210bedd474c3481)) + + + +## [5.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.1...@blockly/field-multilineinput@5.0.2) (2024-06-06) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [5.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@5.0.0...@blockly/field-multilineinput@5.0.1) (2024-05-30) + + +### Bug Fixes + +* multiple blockly instances ([#2375](https://github.com/google/blockly-samples/issues/2375)) ([b231e59](https://github.com/google/blockly-samples/commit/b231e598f2f5f5b0abbfd01d981e35572ad50a26)) + + + +## [5.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@4.0.4...@blockly/field-multilineinput@5.0.0) (2024-05-21) + + +### ⚠ BREAKING CHANGES + +* update all plugins to v11 of blockly + +### Features + +* update all plugins to v11 of blockly ([5fcd3e7](https://github.com/google/blockly-samples/commit/5fcd3e7d53eaadffe9bda9a378b404d34b2f8be2)) +* upgrade ts version, fix field validators ([b2d2191](https://github.com/google/blockly-samples/commit/b2d2191ff11b30347b33f95acf6e58f7ce54d004)) + + +### Bug Fixes + +* Call blocks handle both manual disabling and disabled defs ([#2334](https://github.com/google/blockly-samples/issues/2334)) ([5eade55](https://github.com/google/blockly-samples/commit/5eade55779c4022d14ad4472ff32c93c78199887)) + + + +## [4.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@4.0.3...@blockly/field-multilineinput@4.0.4) (2024-05-16) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [4.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@4.0.2...@blockly/field-multilineinput@4.0.3) (2024-05-09) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [4.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@4.0.1...@blockly/field-multilineinput@4.0.2) (2024-04-25) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [4.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@4.0.0...@blockly/field-multilineinput@4.0.1) (2024-04-11) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [4.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.14...@blockly/field-multilineinput@4.0.0) (2024-04-04) + + +### ⚠ BREAKING CHANGES + +* The multiline text input field no longer registers itself on load. The developer must either manually register the field or install blocks, which will install the field. This is part of a move to have no side effects in field and block definitions, so that tree-shaking can remove unwanted fields and blocks. +* The angle field no longer registers itself on load. The developer must manually register the field. This is part of a move to have no side effects in field and block definitions, so that tree-shaking can remove unwanted fields and blocks. + +### Features + +* add block definitions to fields ([#2299](https://github.com/google/blockly-samples/issues/2299)) ([c197af6](https://github.com/google/blockly-samples/commit/c197af6c67bc989bbdb4e83a62abc08167bcbbfe)), closes [#2162](https://github.com/google/blockly-samples/issues/2162) [#2202](https://github.com/google/blockly-samples/issues/2202) [#2221](https://github.com/google/blockly-samples/issues/2221) [#2211](https://github.com/google/blockly-samples/issues/2211) [#2220](https://github.com/google/blockly-samples/issues/2220) [#2232](https://github.com/google/blockly-samples/issues/2232) [#2233](https://github.com/google/blockly-samples/issues/2233) [#2296](https://github.com/google/blockly-samples/issues/2296) + + + +## [3.0.14](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.13...@blockly/field-multilineinput@3.0.14) (2024-03-28) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.13](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.12...@blockly/field-multilineinput@3.0.13) (2024-02-29) + + +### Bug Fixes + +* updated field multiline to have enough width ([#2208](https://github.com/google/blockly-samples/issues/2208)) ([376691d](https://github.com/google/blockly-samples/commit/376691df5b8dad2e4bd5851f6c1d6a1fa5b6d1e3)) + + + +## [3.0.12](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.11...@blockly/field-multilineinput@3.0.12) (2024-02-08) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.11](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.10...@blockly/field-multilineinput@3.0.11) (2023-12-14) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.10](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.9...@blockly/field-multilineinput@3.0.10) (2023-12-07) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.9](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.8...@blockly/field-multilineinput@3.0.9) (2023-11-09) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.8](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.7...@blockly/field-multilineinput@3.0.8) (2023-11-02) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.7](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.6...@blockly/field-multilineinput@3.0.7) (2023-10-30) + + +### Bug Fixes + +* [#1905](https://github.com/google/blockly-samples/issues/1905) publish types for field-multiline ([#1957](https://github.com/google/blockly-samples/issues/1957)) ([4537b8c](https://github.com/google/blockly-samples/commit/4537b8c6479abf29ebb51fdccf6842c9a88e79a1)) +* **packaging:** Don't advertise non-existent ESM entrypoints ([#2022](https://github.com/google/blockly-samples/issues/2022)) ([d019198](https://github.com/google/blockly-samples/commit/d0191984399b784e2928b8fb4c58257bfa857655)), closes [#1877](https://github.com/google/blockly-samples/issues/1877) +* update tsconfigs to publish types accurately ([df4b084](https://github.com/google/blockly-samples/commit/df4b0844af712f5025a2ec842458b828f3147676)) +* use includes rather than excludes ([40efc25](https://github.com/google/blockly-samples/commit/40efc255329e3ca476ccc247b95a2d05dd77b45e)) + + + +## [3.0.6](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.5...@blockly/field-multilineinput@3.0.6) (2023-09-21) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.5](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.4...@blockly/field-multilineinput@3.0.5) (2023-09-14) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.4](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.3...@blockly/field-multilineinput@3.0.4) (2023-08-31) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.3](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.2...@blockly/field-multilineinput@3.0.3) (2023-08-17) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.2](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.1...@blockly/field-multilineinput@3.0.2) (2023-07-24) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@3.0.0...@blockly/field-multilineinput@3.0.1) (2023-07-20) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [3.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@2.0.1...@blockly/field-multilineinput@3.0.0) (2023-07-13) + + +### ⚠ BREAKING CHANGES + +* updated `plugins/field-date` to TypeScript (#1705) + +### Features + +* updated `plugins/field-date` to TypeScript ([#1705](https://github.com/google/blockly-samples/issues/1705)) ([e5531ff](https://github.com/google/blockly-samples/commit/e5531fffe188ee361a16fe48ed126b34e51a8d30)) + + + +## [2.0.1](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@2.0.0...@blockly/field-multilineinput@2.0.1) (2023-07-06) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [2.0.0](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@1.2.1...@blockly/field-multilineinput@2.0.0) (2023-06-28) + + +### ⚠ BREAKING CHANGES + +* update all plugins to use blockly-v10.0.0 (#1744) + +### Features + +* update all plugins to use blockly-v10.0.0 ([#1744](https://github.com/google/blockly-samples/issues/1744)) ([6cc88cb](https://github.com/google/blockly-samples/commit/6cc88cbef39d4ad664a668d3d46eb29ba7292f9c)) + + + +## [1.2.1](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@1.2.0...@blockly/field-multilineinput@1.2.1) (2023-06-22) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## [1.2.0](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@1.1.1...@blockly/field-multilineinput@1.2.0) (2023-05-25) + + +### Features + +* Revise fields ([#1700](https://github.com/google/blockly-samples/issues/1700)) ([c326f7b](https://github.com/google/blockly-samples/commit/c326f7b1ef54fe45387046c693ebd5afe2b2c571)) + + + +## [1.1.1](https://github.com/google/blockly-samples/compare/@blockly/field-multilineinput@1.1.0...@blockly/field-multilineinput@1.1.1) (2023-05-18) + +**Note:** Version bump only for package @blockly/field-multilineinput + + + + + +## 1.1.0 (2023-05-11) + + +### Features + +* Add angle, colour, and multiline fields. (Rebase of [#1674](https://github.com/google/blockly-samples/issues/1674)) ([#1693](https://github.com/google/blockly-samples/issues/1693)) ([1b447f4](https://github.com/google/blockly-samples/commit/1b447f41d6293f14e846b8a4d82d289a5637a99a)) diff --git a/packages/plugins/field-multilineinput/README.md b/packages/plugins/field-multilineinput/README.md new file mode 100644 index 00000000000..002e50a79ea --- /dev/null +++ b/packages/plugins/field-multilineinput/README.md @@ -0,0 +1,237 @@ +# @blockly/field-multilineinput [![Built on Blockly](https://tinyurl.com/built-on-blockly)](https://github.com/google/blockly) + +A [Blockly](https://www.npmjs.com/package/blockly) multiline input field and +associated block. + +#### Multiline input field + +![A block with the label "multiline text input:" and a multiline input field +with the text "default text \n with newline character.](readme-media/on_block.png) + +#### Multiline input field with editor open + +![The same block with editor open.](readme-media/with_editor.png) + +#### Multiline input on collapsed block + +![The same block after being collapsed. It has the label "multiline text input: +defau..." and a jagged right edge to show it is collapsed.](readme-media/collapsed.png) + +## Installation + +### Yarn + +``` +yarn add @blockly/field-multilineinput +``` + +### npm + +``` +npm install @blockly/field-multilineinput --save +``` + +## Usage + +This plugin adds a field of type `FieldMultilineInput` that is registered with +the name `'field_multilinetext'`. It is a subclass of `Blockly.FieldInput`. + +This field stores a string as its value and a string as its text. Its value is +always a valid string, while its text could be any string entered into its +editor. Unlike a text input field, this field also supports newline characters +entered in the editor. + +#### Editor keyboard shortcuts + +The default keyboard mapping is: + +| Key | Action | +| ----------- | ------------------------------------------------- | +| Enter | Commit the value and close the editor | +| Shift+Enter | Insert a newline at the cursor | +| Escape | Revert to the original value and close the editor | + +With `FieldMultilineInput.enterCommits = false` the mapping is swapped: + +| Key | Action | +| ----------- | ------------------------------------------------- | +| Enter | Insert a newline at the cursor | +| Shift+Enter | Commit the value and close the editor | +| Escape | Revert to the original value and close the editor | + +`enterCommits` and `showHint` are global (static) settings that apply to all +fields. Set them once before creating your blocks: + +```js +import {FieldMultilineInput} from '@blockly/field-multilineinput'; + +FieldMultilineInput.enterCommits = false; // default: true +FieldMultilineInput.showHint = false; // default: true +``` + +The constructor for this field accepts three optional parameters: + +- `value`: The default text. Defaults to `""`. +- `validator`: A function that is called to validate what the user entered. +- `config`: An object with optional properties: + - `maxLines`: The maximum number of lines displayed before scrolling + functionality is enabled. Defaults to `Infinity`. + - `spellcheck`: Whether spell checking is enabled. Defaults to `true`. + - `tooltip`: A tooltip. + +If you want to use only the field, you must register it with Blockly. You can +do this by calling `registerFieldMultilineInput` before instantiating your +blocks. If another field is registered under the same name, this field will +overwrite it. + +### JavaScript + +```js +import * as Blockly from 'blockly'; +import {registerFieldMultilineInput} from '@blockly/field-multilineinput'; + +registerFieldMultilineInput(); +Blockly.Blocks['test_field_multilineinput'] = { + init: function () { + this.appendDummyInput() + .appendField('multilineinput: ') + .appendField( + new FieldMultilineInput('some text \n with newlines'), + 'FIELDNAME', + ); + }, +}; +``` + +### JSON + +```js +import * as Blockly from 'blockly'; +import {registerFieldMultilineInput} from '@blockly/field-multilineinput'; + +registerFieldMultilineInput(); +Blockly.defineBlocksWithJsonArray([ + { + "type": "test_field_multilinetext", + "message0": "multilineinput: %1", + "args0": [ + { + "type": "field_multilinetext", + "name": "FIELDNAME", + "text": "some text \n with newlines" + } + }]); +``` + +### Customization + +#### Spellcheck + +The `setSpellcheck` function can be used to set whether the field spellchecks +its input text or not. Spellchecking is on by default. + +![An animation showing multiline text input fields with and without +spellcheck.](readme-media/spellcheck.gif) + +This applies to individual fields. If you want to modify all fields change the +`Blockly.FieldMultilineInput.prototype.spellcheck_` property. + +#### Validation + +A multiline text input field's value is a string, so any validators must accept +a string and return a string, `null`, or `undefined`. + +Here is an example of a validator that removes all 'a' characters from the +string: + +``` +function(newValue) { + return newValue.replace(/a/gm, ''); +} +``` + +![An animation showing validation.](readme-media/validator.gif) + +### Serialization + +#### JSON + +The JSON for a multiline text input field looks like so: + +```json +{ + "fields": { + "FIELDNAME": "line1\nline2" + } +} +``` + +where `FIELDNAME` is a string referencing a multiline text input field, and the +value is the value to apply to the field. The value follows the same rules as +the constructor value. + +#### XML + +The XML for a multiline text input field looks like so: + +```xml +line1&#10;line2 +``` + +where the field's `name` attribute contains a string referencing a multiline +text input field, and the inner text is the value to apply to the field. The +inner text value follows the same rules as the constructor value. + +### Blocks + +This package also provides a simple block containing a multiline input +field. It has generators in JavaScript, Python, PHP, Lua, and Dart. + +You can install the block by calling `textMultiline.installBlock()`. +This will install the block and all of its dependencies, including the +multiline input field. When calling `installBlock` you can supply one or +more `CodeGenerator` instances (e.g. `javascriptGenerator`), and the install +function will also install the correct generator function for the +corresponding language(s). + +```js +import {javascriptGenerator} from 'blockly/javascript'; +import {dartGenerator} from 'blockly/dart'; +import {phpGenerator} from 'blockly/php'; +import {pythonGenerator} from 'blockly/python'; +import {luaGenerator} from 'blockly/lua'; +import {textMultiline} from '@blockly/field-multilineinput'; + +// Installs the block, the field, and all of the language generators. +textMultiline.installBlock({ + javascript: javascriptGenerator, + dart: dartGenerator, + lua: luaGenerator, + python: pythonGenerator, + php: phpGenerator, +}); +``` + +### API reference + +Instance methods: + +- `setMaxLines`: Sets the maximum number of displayed lines before + scrolling functionality is enabled. +- `getMaxLines`: Returns the maximum number of displayed lines before + scrolling functionality is enabled. +- `setSpellcheck`: Sets whether spell checking is enabled. +- `getSpellcheck`: Returns whether spell checking is enabled. + +Static (global) properties: + +- `FieldMultilineInput.enterCommits`: Whether pressing Enter commits the + value (`true`, default) or inserts a newline (`false`). Applies to all + fields. +- `FieldMultilineInput.showHint`: Whether the keyboard-shortcut hint bar is + shown in the editor. When `false`, no space is reserved for the hint bar. + Applies to all fields. + +## License + +Apache 2.0 diff --git a/packages/plugins/field-multilineinput/package.json b/packages/plugins/field-multilineinput/package.json new file mode 100644 index 00000000000..dfaa02ec767 --- /dev/null +++ b/packages/plugins/field-multilineinput/package.json @@ -0,0 +1,55 @@ +{ + "name": "@blockly/field-multilineinput", + "version": "13.1.0", + "description": "A Blockly multilineinput field.", + "scripts": { + "audit:fix": "blockly-scripts auditFix", + "build": "blockly-scripts build", + "clean": "blockly-scripts clean", + "lint": "eslint .", + "predeploy": "blockly-scripts predeploy", + "prepublishOnly": "npm run clean && npm run build", + "start": "blockly-scripts start", + "test": "blockly-scripts test" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "unpkg": "./dist/index.js", + "author": "Blockly Team", + "keywords": [ + "blockly", + "field", + "multilineinput" + ], + "homepage": "https://github.com/RaspberryPiFoundation/blockly/tree/main/packages/plugins/field-multilineinput#readme", + "bugs": { + "url": "https://github.com/RaspberryPiFoundation/blockly/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/RaspberryPiFoundation/blockly.git", + "directory": "packages/plugins/field-multilineinput" + }, + "license": "Apache-2.0", + "directories": { + "dist": "dist", + "src": "src" + }, + "files": [ + "dist", + "src" + ], + "devDependencies": { + "@blockly/dev-scripts": "^13.1.0", + "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", + "sinon": "^22.0.0", + "typescript": "^6.0.3" + }, + "peerDependencies": { + "blockly": "^13.2.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/plugins/field-multilineinput/readme-media/collapsed.png b/packages/plugins/field-multilineinput/readme-media/collapsed.png new file mode 100644 index 00000000000..a227642a705 Binary files /dev/null and b/packages/plugins/field-multilineinput/readme-media/collapsed.png differ diff --git a/packages/plugins/field-multilineinput/readme-media/on_block.png b/packages/plugins/field-multilineinput/readme-media/on_block.png new file mode 100644 index 00000000000..6a52a4aa926 Binary files /dev/null and b/packages/plugins/field-multilineinput/readme-media/on_block.png differ diff --git a/packages/plugins/field-multilineinput/readme-media/spellcheck.gif b/packages/plugins/field-multilineinput/readme-media/spellcheck.gif new file mode 100644 index 00000000000..3f1ae53fa9e Binary files /dev/null and b/packages/plugins/field-multilineinput/readme-media/spellcheck.gif differ diff --git a/packages/plugins/field-multilineinput/readme-media/validator.gif b/packages/plugins/field-multilineinput/readme-media/validator.gif new file mode 100644 index 00000000000..b9e18a1dc06 Binary files /dev/null and b/packages/plugins/field-multilineinput/readme-media/validator.gif differ diff --git a/packages/plugins/field-multilineinput/readme-media/with_editor.png b/packages/plugins/field-multilineinput/readme-media/with_editor.png new file mode 100644 index 00000000000..98dacf3a907 Binary files /dev/null and b/packages/plugins/field-multilineinput/readme-media/with_editor.png differ diff --git a/packages/plugins/field-multilineinput/src/blocks/generatorsType.ts b/packages/plugins/field-multilineinput/src/blocks/generatorsType.ts new file mode 100644 index 00000000000..e9d2df4609c --- /dev/null +++ b/packages/plugins/field-multilineinput/src/blocks/generatorsType.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {JavascriptGenerator} from 'blockly/javascript'; +import type {DartGenerator} from 'blockly/dart'; +import type {LuaGenerator} from 'blockly/lua'; +import type {PhpGenerator} from 'blockly/php'; +import type {PythonGenerator} from 'blockly/python'; + +/** + * An object containing zero or more generators. This is passed + * to block installation functions so that they may install + * per-block generators on any languages they support. + */ +export interface Generators { + javascript?: JavascriptGenerator; + dart?: DartGenerator; + lua?: LuaGenerator; + php?: PhpGenerator; + python?: PythonGenerator; +} diff --git a/packages/plugins/field-multilineinput/src/blocks/textMultiline.ts b/packages/plugins/field-multilineinput/src/blocks/textMultiline.ts new file mode 100644 index 00000000000..831c79ed10b --- /dev/null +++ b/packages/plugins/field-multilineinput/src/blocks/textMultiline.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Block, common as BlocklyCommon} from 'blockly/core'; +import { + JavascriptGenerator, + Order as JavascriptOrder, +} from 'blockly/javascript'; +import {DartGenerator, Order as DartOrder} from 'blockly/dart'; +import {LuaGenerator, Order as LuaOrder} from 'blockly/lua'; +import {PhpGenerator, Order as PhpOrder} from 'blockly/php'; +import {PythonGenerator, Order as PythonOrder} from 'blockly/python'; +import {registerFieldMultilineInput} from '../field_multilineinput'; +import {Generators} from './generatorsType'; + +/** The name this block is registered under. */ +export const BLOCK_NAME = 'text_multiline'; + +// Block for multiline text input. +const jsonDefinition = { + type: BLOCK_NAME, + message0: '%1 %2', + args0: [ + { + type: 'field_image', + src: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAARCAYAAADpP' + + 'U2iAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAAdhgAAHYYBXaITgQAAABh0RVh0' + + 'U29mdHdhcmUAcGFpbnQubmV0IDQuMS42/U4J6AAAAP1JREFUOE+Vks0KQUEYhjm' + + 'RIja4ABtZ2dm5A3t3Ia6AUm7CylYuQRaUhZSlLZJiQbFAyRnPN33y01HOW08z88' + + '73zpwzM4F3GWOCruvGIE4/rLaV+Nq1hVGMBqzhqlxgCys4wJA65xnogMHsQ5luj' + + 'nYHTejBBCK2mE4abjCgMGhNxHgDFWjDSG07kdfVa2pZMf4ZyMAdWmpZMfYOsLiD' + + 'MYMjlMB+K613QISRhTnITnsYg5yUd0DETmEoMlkFOeIT/A58iyK5E18BuTBfgYX' + + 'fwNJv4P9/oEBerLylOnRhygmGdPpTTBZAPkde61lbQe4moWUvYUZYLfUNftIY4z' + + 'wA5X2Z9AYnQrEAAAAASUVORK5CYII=', + width: 12, + height: 17, + alt: '\u00B6', + }, + { + type: 'field_multilinetext', + name: 'TEXT', + text: '', + }, + ], + output: 'String', + style: 'text_blocks', + helpUrl: '%{BKY_TEXT_TEXT_HELPURL}', + tooltip: '%{BKY_TEXT_TEXT_TOOLTIP}', + extensions: ['parent_tooltip_when_inline'], +}; + +/** + * Javascript block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The JavascriptGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toJavascript( + block: Block, + generator: JavascriptGenerator, +): [string, JavascriptOrder] { + // Text value. + const code = generator.multiline_quote_(block.getFieldValue('TEXT')); + const order = + code.indexOf('+') !== -1 + ? JavascriptOrder.ADDITION + : JavascriptOrder.ATOMIC; + return [code, order]; +} + +/** + * Dart block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The DartGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toDart( + block: Block, + generator: DartGenerator, +): [string, DartOrder] { + // Text value. + const code = generator.multiline_quote_(block.getFieldValue('TEXT')); + const order = + code.indexOf('+') !== -1 ? DartOrder.ADDITIVE : DartOrder.ATOMIC; + return [code, order]; +} + +/** + * Lua block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The LuaGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toLua( + block: Block, + generator: LuaGenerator, +): [string, LuaOrder] { + // Text value. + const code = generator.multiline_quote_(block.getFieldValue('TEXT')); + const order = + code.indexOf('..') !== -1 ? LuaOrder.CONCATENATION : LuaOrder.ATOMIC; + return [code, order]; +} + +/** + * PHP block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The PhpGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPhp( + block: Block, + generator: PhpGenerator, +): [string, PhpOrder] { + // Text value. + const code = generator.multiline_quote_(block.getFieldValue('TEXT')); + const order = + code.indexOf('.') !== -1 ? PhpOrder.STRING_CONCAT : PhpOrder.ATOMIC; + return [code, order]; +} + +/** + * Python block generator function. + * + * @param block The Block instance to generate code for. + * @param generator The PythonGenerator calling the function. + * @returns A tuple containing the code string and precedence. + */ +export function toPython( + block: Block, + generator: PythonGenerator, +): [string, PythonOrder] { + // Text value. + const code = generator.multiline_quote_(block.getFieldValue('TEXT')); + const order = + code.indexOf('+') !== -1 ? PythonOrder.ADDITIVE : PythonOrder.ATOMIC; + return [code, order]; +} + +const definitionsDict = BlocklyCommon.createBlockDefinitionsFromJsonArray([ + jsonDefinition, +]); + +/** The text_multiline BlockDefinition. */ +export const blockDefinition = definitionsDict[BLOCK_NAME]; + +/** + * Install the `text_multiline` block and all of its dependencies. + * + * @param gens The CodeGenerators to install per-block + * generators on. + */ +export function installBlock(gens: Generators = {}) { + registerFieldMultilineInput(); + BlocklyCommon.defineBlocks(definitionsDict); + if (gens.javascript) gens.javascript.forBlock[BLOCK_NAME] = toJavascript; + if (gens.dart) gens.dart.forBlock[BLOCK_NAME] = toDart; + if (gens.lua) gens.lua.forBlock[BLOCK_NAME] = toLua; + if (gens.php) gens.php.forBlock[BLOCK_NAME] = toPhp; + if (gens.python) gens.python.forBlock[BLOCK_NAME] = toPython; +} diff --git a/packages/plugins/field-multilineinput/src/field_multilineinput.ts b/packages/plugins/field-multilineinput/src/field_multilineinput.ts new file mode 100644 index 00000000000..fd3f6083172 --- /dev/null +++ b/packages/plugins/field-multilineinput/src/field_multilineinput.ts @@ -0,0 +1,781 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Multiline text input field. + */ + +import * as Blockly from 'blockly/core'; + +/** + * Class for an editable text area input field. + */ +export class FieldMultilineInput extends Blockly.FieldTextInput { + /** + * Minimum editor width (in SVG/workspace units) when the field is open. + * Prevents narrow editors when the initial text is very short. + */ + + static readonly EDITOR_MIN_WIDTH = 150; + + /** + * The SVG group element that will contain a text element for each text row + * when initialized. + */ + textGroup: SVGGElement | null = null; + + /** + * Defines the maximum number of lines of field. + * If exceeded, scrolling functionality is enabled. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected maxLines_ = Infinity; + + /** Whether Y overflow is currently occurring. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected isOverflowedY_ = false; + + /** Whether pressing Enter commits the value (default) or inserts a newline. */ + static enterCommits = true; + + /** Whether to show the keyboard-shortcut hint bar in the editor. */ + static showHint = true; + + /** The hint bar DOM element while the editor is open. */ + private hintElement: HTMLDivElement | null = null; + + /** + * Cached natural width of the hint bar in px, keyed by scale. + * Invalidated when zoom changes to avoid stale measurements. + */ + private cachedHintWidth: {scale: number; widthPx: number} | null = null; + + /** + * @param value The initial content of the field. Should cast to a string. + * Defaults to an empty string if null or undefined. Also accepts + * Field.SKIP_SETUP if you wish to skip setup (only used by subclasses + * that want to handle configuration and setting the field value after + * their own constructors have run). + * @param validator An optional function that is called to validate any + * constraints on what the user entered. Takes the new text as an + * argument and returns either the accepted text, a replacement text, or + * null to abort the change. + * @param config A map of options used to configure the field. + * See the [field creation documentation]{@link + * https://developers.google.com/blockly/guides/create-custom-blocks/fields/built-in-fields/multiline-text-input#creation} + * for a list of properties this parameter supports. + */ + constructor( + value?: string | typeof Blockly.Field.SKIP_SETUP, + validator?: FieldMultilineInputValidator, + config?: FieldMultilineInputConfig, + ) { + super(Blockly.Field.SKIP_SETUP); + + if (value === Blockly.Field.SKIP_SETUP) return; + if (config) { + this.configure_(config); + } + this.setValue(value); + if (validator) { + this.setValidator(validator); + } + } + + /** + * Configure the field based on the given map of options. + * + * @param config A map of options to configure the field based on. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override configure_(config: FieldMultilineInputConfig) { + super.configure_(config); + if (config.maxLines) this.setMaxLines(config.maxLines); + } + + /** + * Serializes this field's value to XML. + * Should only be called by Blockly.Xml. + * + * @param fieldElement The element to populate with info about the field's + * state. + * @returns The element containing info about the field's state. + */ + override toXml(fieldElement: Element): Element { + // Replace '\n' characters with HTML-escaped equivalent ' '. This is + // needed so the plain-text representation of the XML produced by + // `Blockly.Xml.domToText` will appear on a single line (this is a + // limitation of the plain-text format). + fieldElement.textContent = (this.getValue() as string).replace( + /\n/g, + ' ', + ); + return fieldElement; + } + + /** + * Sets the field's value based on the given XML element. Should only be + * called by Blockly.Xml. + * + * @param fieldElement The element containing info about the field's state. + */ + override fromXml(fieldElement: Element) { + this.setValue((fieldElement.textContent as string).replace(/ /g, '\n')); + } + + /** + * Saves this field's value. + * This function only exists for subclasses of FieldMultilineInput which + * predate the load/saveState API and only define to/fromXml. + * + * @returns The state of this field. + */ + override saveState() { + const legacyState = this.saveLegacyState(FieldMultilineInput); + if (legacyState !== null) { + return legacyState; + } + return this.getValue(); + } + + /** + * Sets the field's value based on the given state. + * This function only exists for subclasses of FieldMultilineInput which + * predate the load/saveState API and only define to/fromXml. + * + * @param state The state of the variable to assign to this variable field. + */ + override loadState(state: unknown) { + if (this.loadLegacyState(Blockly.Field, state)) { + return; + } + this.setValue(state); + } + + /** + * Create the block UI for this field. + */ + override initView() { + this.createBorderRect_(); + this.textGroup = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.G, + { + class: 'blocklyEditableField', + }, + this.fieldGroup_, + ); + if (this.fieldGroup_) { + Blockly.utils.dom.addClass(this.fieldGroup_, 'blocklyField'); + } + } + + /** + * Get the text from this field as displayed on screen. May differ from + * getText due to ellipsis, and other formatting. + * + * @returns Currently displayed text. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override getDisplayText_(): string { + const block = this.getSourceBlock(); + if (!block) { + throw new Error( + 'The field has not yet been attached to its input. ' + + 'Call appendField to attach it.', + ); + } + let textLines = this.getText(); + if (!textLines) { + // Prevent the field from disappearing if empty. + return Blockly.Field.NBSP; + } + const lines = textLines.split('\n'); + textLines = ''; + const displayLinesNumber = this.isOverflowedY_ + ? this.maxLines_ + : lines.length; + for (let i = 0; i < displayLinesNumber; i++) { + let text = lines[i]; + if (text.length > this.maxDisplayLength) { + // Truncate displayed string and add an ellipsis ('...'). + text = text.substring(0, this.maxDisplayLength - 4) + '...'; + } else if (this.isOverflowedY_ && i === displayLinesNumber - 1) { + text = text.substring(0, text.length - 3) + '...'; + } + // Replace whitespace with non-breaking spaces so the text doesn't + // collapse. + text = text.replace(/\s/g, Blockly.Field.NBSP); + + textLines += text; + if (i !== displayLinesNumber - 1) { + textLines += '\n'; + } + } + if (block.RTL) { + // The SVG is LTR, force value to be RTL. + textLines += '\u200F'; + } + return textLines; + } + + /** + * Called by setValue if the text input is valid. Updates the value of the + * field, and updates the text of the field if it is not currently being + * edited (i.e. handled by the htmlInput_). Is being redefined here to update + * overflow state of the field. + * + * @param newValue The value to be saved. The default validator guarantees + * that this is a string. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override doValueUpdate_(newValue: string) { + super.doValueUpdate_(newValue); + if (this.value_ !== null) { + this.isOverflowedY_ = this.value_.split('\n').length > this.maxLines_; + } + } + + /** Updates the text of the textElement. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override render_() { + const block = this.getSourceBlock(); + if (!block) { + throw new Error( + 'The field has not yet been attached to its input. ' + + 'Call appendField to attach it.', + ); + } + // Remove all text group children. + let currentChild; + const textGroup = this.textGroup as SVGElement; + while ((currentChild = textGroup.firstChild)) { + textGroup.removeChild(currentChild); + } + + const constants = this.getConstants(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!constants) throw Error('Constants not found'); + // Add in text elements into the group. + const lines = this.getDisplayText_().split('\n'); + let y = 0; + for (let i = 0; i < lines.length; i++) { + const lineHeight = + constants.FIELD_TEXT_HEIGHT + constants.FIELD_BORDER_RECT_Y_PADDING; + const span = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.TEXT, + { + class: 'blocklyText blocklyMultilineText', + x: constants.FIELD_BORDER_RECT_X_PADDING, + y: y + constants.FIELD_BORDER_RECT_Y_PADDING, + dy: constants.FIELD_TEXT_BASELINE, + }, + textGroup, + ); + span.appendChild(document.createTextNode(lines[i])); + y += lineHeight; + } + + if (this.isBeingEdited_) { + const htmlInput = this.htmlInput_ as HTMLElement; + if (this.isOverflowedY_) { + Blockly.utils.dom.addClass( + htmlInput, + 'blocklyHtmlTextAreaInputOverflowedY', + ); + } else { + Blockly.utils.dom.removeClass( + htmlInput, + 'blocklyHtmlTextAreaInputOverflowedY', + ); + } + } + + this.updateSize_(); + + if (this.isBeingEdited_) { + if (block.RTL) { + // in RTL, we need to let the browser reflow before resizing + // in order to get the correct bounding box of the borderRect + // avoiding issue #2777. + setTimeout(this.resizeEditor_.bind(this), 0); + } else { + this.resizeEditor_(); + } + const htmlInput = this.htmlInput_ as HTMLElement; + if (!this.isTextValid_) { + Blockly.utils.dom.addClass(htmlInput, 'blocklyInvalidInput'); + Blockly.utils.aria.setState( + htmlInput, + Blockly.utils.aria.State.INVALID, + true, + ); + } else { + Blockly.utils.dom.removeClass(htmlInput, 'blocklyInvalidInput'); + Blockly.utils.aria.setState( + htmlInput, + Blockly.utils.aria.State.INVALID, + false, + ); + } + } + } + + /** Updates the size of the field based on the text. */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override updateSize_() { + const constants = this.getConstants(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!constants) throw Error('Constants not found'); + const nodes = (this.textGroup as SVGElement).childNodes; + let totalWidth = 0; + let totalHeight = 0; + for (let i = 0; i < nodes.length; i++) { + const tspan = nodes[i] as SVGTextElement; + const textWidth = Blockly.utils.dom.getTextWidth(tspan); + if (textWidth > totalWidth) { + totalWidth = textWidth; + } + totalHeight += + constants.FIELD_TEXT_HEIGHT + + (i > 0 ? constants.FIELD_BORDER_RECT_Y_PADDING : 0); + } + if (this.isBeingEdited_) { + // The default width is based on the longest line in the display text, + // but when it's being edited, width should be calculated based on the + // absolute longest line, even if it would be truncated after editing. + // Otherwise we would get wrong editor width when there are more + // lines than this.maxLines_. + const actualEditorLines = String(this.value_).split('\n'); + const dummyTextElement = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.TEXT, + {class: 'blocklyText blocklyMultilineText'}, + ); + + for (let i = 0; i < actualEditorLines.length; i++) { + if (actualEditorLines[i].length > this.maxDisplayLength) { + actualEditorLines[i] = actualEditorLines[i].substring( + 0, + this.maxDisplayLength, + ); + } + dummyTextElement.textContent = actualEditorLines[i]; + const lineWidth = Blockly.utils.dom.getTextWidth(dummyTextElement); + if (lineWidth > totalWidth) { + totalWidth = lineWidth; + } + } + + const htmlInput = this.htmlInput_ as HTMLElement; + const scrollbarWidth = htmlInput.offsetWidth - htmlInput.clientWidth; + totalWidth += scrollbarWidth; + + if (FieldMultilineInput.showHint) { + // Reserve a row at the bottom of the editor for the keyboard hint bar. + // The textarea's padding-bottom (set in widgetCreate_) keeps the text + // and caret out of this reserved strip. + totalHeight += + constants.FIELD_TEXT_HEIGHT + constants.FIELD_BORDER_RECT_Y_PADDING; + } + } + if (this.borderRect_) { + totalHeight += constants.FIELD_BORDER_RECT_Y_PADDING * 2; + // NOTE: Adding 1 extra px to prevent wrapping. Based on browser zoom, + // the rounding of the calculated value can result in the line wrapping + // unintentionally. + totalWidth += constants.FIELD_BORDER_RECT_X_PADDING * 2 + 1; + } + if (this.isBeingEdited_) { + totalWidth = Math.max(totalWidth, FieldMultilineInput.EDITOR_MIN_WIDTH); + + // Measure the hint bar's natural width to make sure the editor is wide enough. + if (FieldMultilineInput.showHint && this.hintElement?.isConnected) { + const scale = (this.workspace_ as Blockly.WorkspaceSvg).getScale(); + if (!this.cachedHintWidth || this.cachedHintWidth.scale !== scale) { + // Temporarily let the element size to its content to measure its + // natural width, unaffected by the current WidgetDiv width. + this.hintElement.style.width = 'max-content'; + const widthPx = this.hintElement.offsetWidth; + this.hintElement.style.width = ''; + if (widthPx > 0) this.cachedHintWidth = {scale, widthPx}; + } + if (this.cachedHintWidth) { + totalWidth = Math.max( + totalWidth, + this.cachedHintWidth.widthPx / scale, + ); + } + } + } + if (this.borderRect_) { + this.borderRect_.setAttribute('width', `${totalWidth}`); + this.borderRect_.setAttribute('height', `${totalHeight}`); + } + this.size_.width = totalWidth; + this.size_.height = totalHeight; + + this.positionBorderRect_(); + } + + /** + * Show the inline free-text editor on top of the text. + * Overrides the default behaviour to force rerender in order to + * correct block size, based on editor text. + * + * @param e Optional mouse event that triggered the field to open, or + * undefined if triggered programmatically. + * @param quietInput True if editor should be created without focus. + * Defaults to false. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + override showEditor_(e?: Event, quietInput?: boolean) { + super.showEditor_(e, quietInput); + this.forceRerender(); + } + + /** + * Create the text input editor widget. + * + * @returns The newly created text input editor. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override widgetCreate_(): HTMLTextAreaElement { + const div = Blockly.WidgetDiv.getDiv() as HTMLDivElement; + const scale = (this.workspace_ as Blockly.WorkspaceSvg).getScale(); + const constants = this.getConstants(); + // This can't happen, but TypeScript thinks it can and lint forbids `!.`. + if (!constants) throw Error('Constants not found'); + + const htmlInput = document.createElement('textarea'); + htmlInput.className = 'blocklyHtmlInput blocklyHtmlTextAreaInput'; + htmlInput.setAttribute('spellcheck', String(this.spellcheck_)); + const fontSize = constants.FIELD_TEXT_FONTSIZE * scale + 'pt'; + div.style.fontSize = fontSize; + htmlInput.style.fontSize = fontSize; + const borderRadius = Blockly.FieldTextInput.BORDERRADIUS * scale + 'px'; + htmlInput.style.borderRadius = borderRadius; + const paddingX = constants.FIELD_BORDER_RECT_X_PADDING * scale; + const paddingY = (constants.FIELD_BORDER_RECT_Y_PADDING * scale) / 2; + const lineHeight = + constants.FIELD_TEXT_HEIGHT + constants.FIELD_BORDER_RECT_Y_PADDING; + const hintHeightPx = Math.ceil(lineHeight * scale); + // When the hint bar is visible it occupies one text row at the bottom; + // pad the textarea by that row so we don't edit text underneath it. + const paddingBottomPx = FieldMultilineInput.showHint + ? paddingY + hintHeightPx + : paddingY; + htmlInput.style.padding = `${paddingY}px ${paddingX}px ${paddingBottomPx}px ${paddingX}px`; + htmlInput.style.lineHeight = lineHeight * scale + 'px'; + div.appendChild(htmlInput); + + htmlInput.value = htmlInput.defaultValue = this.getEditorText_(this.value_); + htmlInput.setAttribute('data-untyped-default-value', String(this.value_)); + htmlInput.setAttribute('data-old-value', ''); + if (Blockly.utils.userAgent.GECKO) { + // In FF, ensure the browser reflows before resizing to avoid issue #2777. + setTimeout(this.resizeEditor_.bind(this), 0); + } else { + this.resizeEditor_(); + } + + this.bindInputEvents_(htmlInput); + + if (FieldMultilineInput.showHint) { + this.hintElement = this.createHint(hintHeightPx); + this.hintElement.style.fontFamily = constants.FIELD_TEXT_FONTFAMILY; + div.appendChild(this.hintElement); + } + + return htmlInput; + } + + /** + * Creates the keyboard hint bar shown at the bottom of the editor. + * + * Keys are rendered as keycaps (bordered boxes). + * + * @param heightPx The height of the hint bar in pixels. + * @returns The hint bar element. + */ + private createHint(heightPx: number): HTMLDivElement { + const shiftKey = '⇧'; + const enterKey = '⏎'; + const hint = document.createElement('div'); + hint.className = 'blocklyMultilineHint'; + hint.setAttribute('aria-hidden', 'true'); + hint.style.height = heightPx + 'px'; + + // Plain Enter + const enterGroup = document.createElement('div'); + enterGroup.className = 'blocklyMultilineHintGroup'; + enterGroup.appendChild(this.createHintKeycap(enterKey)); + enterGroup.appendChild(this.createHintColon()); + enterGroup.appendChild( + this.createHintLabel( + FieldMultilineInput.enterCommits ? 'commit' : 'newline', + ), + ); + hint.appendChild(enterGroup); + + // Modifier+Enter + const modEnterGroup = document.createElement('div'); + modEnterGroup.className = 'blocklyMultilineHintGroup'; + modEnterGroup.appendChild(this.createHintKeycap(shiftKey)); + modEnterGroup.appendChild(this.createHintKeycap(enterKey)); + modEnterGroup.appendChild(this.createHintColon()); + modEnterGroup.appendChild( + this.createHintLabel( + FieldMultilineInput.enterCommits ? 'newline' : 'commit', + ), + ); + hint.appendChild(modEnterGroup); + + return hint; + } + + /** + * Creates a colon separator element used between keycaps and the action icon. + * + * @returns The colon element. + */ + private createHintColon(): HTMLSpanElement { + const colon = document.createElement('span'); + colon.className = 'blocklyMultilineHintColon'; + colon.textContent = ':'; + return colon; + } + + /** + * Creates a keycap element displaying a single key label. + * + * @param label The key symbol to display. + * @returns The keycap element. + */ + private createHintKeycap(label: string): HTMLSpanElement { + const key = document.createElement('span'); + key.className = 'blocklyMultilineHintKey'; + key.textContent = label; + return key; + } + + /** + * Creates a text label describing the result of a key action. + * + * Uses Blockly.Msg for i18n with hardcoded English fallbacks. + * + * @param type Either 'commit' or 'newline'. + * @returns The label element. + */ + private createHintLabel(type: 'commit' | 'newline'): HTMLSpanElement { + const label = document.createElement('span'); + label.className = 'blocklyMultilineHintLabel'; + label.textContent = + type === 'commit' + ? Blockly.Msg['FIELD_MULTILINEINPUT_FINISH_EDITING'] || 'Finish editing' + : Blockly.Msg['FIELD_MULTILINEINPUT_NEW_LINE'] || 'New line'; + return label; + } + + /** + * Sets the maxLines config for this field. + * + * @param maxLines Defines the maximum number of lines allowed, before + * scrolling functionality is enabled. + */ + setMaxLines(maxLines: number) { + if ( + typeof maxLines === 'number' && + maxLines > 0 && + maxLines !== this.maxLines_ + ) { + this.maxLines_ = maxLines; + this.forceRerender(); + } + } + + /** + * Returns the maxLines config of this field. + * + * @returns The maxLines config value. + */ + getMaxLines(): number { + return this.maxLines_; + } + + /** + * Handle key down to the editor. + * + * Enter commits the value and closes the editor (matching FieldTextInput). + * Shift+Enter inserts a newline at the cursor. + * All other keys are handled by the parent class (Escape reverts, Tab navigates). + * + * @param e Keyboard event. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected override onHtmlInputKeyDown_(e: KeyboardEvent) { + if (e.key === 'Enter') { + if (e.isComposing) { + // Let the IME (input method editor) finalize its composition naturally; + // don't commit or insert a newline ourselves. + return; + } + const shiftPressed = e.shiftKey; + const shouldCommit = FieldMultilineInput.enterCommits + ? !shiftPressed + : shiftPressed; + if (shouldCommit) { + super.onHtmlInputKeyDown_(e); + } else { + this.insertNewline(); + e.preventDefault(); + e.stopPropagation(); + } + } else { + super.onHtmlInputKeyDown_(e); + } + } + + /** + * Inserts a newline character at the current cursor position in the textarea. + * + * The browser's default Enter-key behavior inserts a newline, but + * Shift+Enter has no default character-insertion action, so we handle it + * manually here. After splicing the character we dispatch an `input` event + * to trigger Blockly's onHtmlInputChange handler (programmatic `.value` + * assignment doesn't fire `input` on its own). + */ + private insertNewline() { + const htmlInput = this.htmlInput_; + if (!htmlInput) return; + const start = htmlInput.selectionStart ?? htmlInput.value.length; + const end = htmlInput.selectionEnd ?? start; + htmlInput.value = + htmlInput.value.substring(0, start) + + '\n' + + htmlInput.value.substring(end); + htmlInput.selectionStart = htmlInput.selectionEnd = start + 1; + htmlInput.dispatchEvent(new Event('input')); + } + + /** + * Construct a FieldMultilineInput from a JSON arg object, + * dereferencing any string table references. + * + * @param options A JSON object with options (text, and spellcheck). + * @returns The new field instance. + * @nocollapse + */ + static override fromJson( + options: FieldMultilineInputFromJsonConfig, + ): FieldMultilineInput { + const text = Blockly.utils.parsing.replaceMessageReferences(options.text); + // `this` might be a subclass of FieldMultilineInput if that class doesn't + // override the static fromJson method. + return new this(text, undefined, options); + } +} + +/** + * Register the field and any dependencies. + */ +export function registerFieldMultilineInput() { + Blockly.fieldRegistry.register('field_multilinetext', FieldMultilineInput); +} + +/** + * CSS for multiline field. + */ +Blockly.Css.register(` +.blocklyHtmlTextAreaInput { + font-family: monospace; + resize: none; + overflow: hidden; + height: 100%; + text-align: left; +} + +.blocklyHtmlTextAreaInputOverflowedY { + overflow-y: scroll; +} + +.blocklyMultilineHint { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + display: flex; + justify-content: space-around; + align-items: center; + padding: 0 4px; + box-sizing: border-box; + font-size: 0.85em; + color: rgba(0, 0, 0, 0.6); + background-color: white; + border-top: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0 0 4px 4px; + user-select: none; + pointer-events: none; +} + +.blocklyMultilineHintGroup { + display: inline-flex; + align-items: center; +} + +.blocklyMultilineHintKey { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.3em; + height: 1.3em; + padding: 0 0.25em; + margin: 0 0.1em; + border: 1px solid rgba(0, 0, 0, 0.35); + border-radius: 3px; + background-color: #f5f5f5; + line-height: 1; + box-sizing: border-box; +} + +.blocklyMultilineHintColon { + margin: 0 0.15em; +} + +.blocklyMultilineHintLabel { + white-space: nowrap; +} +`); + +/** + * Config options for the multiline input field. + */ +export interface FieldMultilineInputConfig + extends Blockly.FieldTextInputConfig { + maxLines?: number; +} + +/** + * fromJson config options for the multiline input field. + */ +export interface FieldMultilineInputFromJsonConfig extends FieldMultilineInputConfig { + text?: string; +} + +/** + * A function that is called to validate changes to the field's value before + * they are set. + * + * @see {@link https://developers.google.com/blockly/guides/create-custom-blocks/fields/validators#return_values} + * @param newValue The value to be validated. + * @returns One of three instructions for setting the new value: `T`, `null`, + * or `undefined`. + * + * - `T` to set this function's returned value instead of `newValue`. + * + * - `null` to invoke `doValueInvalid_` and not set a value. + * + * - `undefined` to set `newValue` as is. + */ +export type FieldMultilineInputValidator = Blockly.FieldTextInputValidator; diff --git a/packages/plugins/field-multilineinput/src/index.ts b/packages/plugins/field-multilineinput/src/index.ts new file mode 100644 index 00000000000..051fded12e4 --- /dev/null +++ b/packages/plugins/field-multilineinput/src/index.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as textMultiline from './blocks/textMultiline'; + +export * from './field_multilineinput'; + +// Re-export all parts of the block definition. +export * as textMultiline from './blocks/textMultiline'; + +// This package currently exports a single block. More may +// be added later. +export const installAllBlocks = textMultiline.installBlock; diff --git a/packages/plugins/field-multilineinput/test/block_test.mocha.js b/packages/plugins/field-multilineinput/test/block_test.mocha.js new file mode 100644 index 00000000000..d60fd2cf156 --- /dev/null +++ b/packages/plugins/field-multilineinput/test/block_test.mocha.js @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'fs'; +import * as Blockly from 'blockly/core'; +import * as en from 'blockly/msg/en'; +import 'blockly/blocks'; + +import {javascriptGenerator} from 'blockly/javascript'; +import {dartGenerator} from 'blockly/dart'; +import {phpGenerator} from 'blockly/php'; +import {pythonGenerator} from 'blockly/python'; +import {luaGenerator} from 'blockly/lua'; +import {installAllBlocks} from '../src/index'; +import {assert} from 'chai'; + +const blockJson = { + blocks: { + languageVersion: 0, + blocks: [ + { + type: 'text_print', + inputs: { + TEXT: { + block: { + type: 'text_multiline', + fields: { + TEXT: 'Picard said, "Beam me up!".\nO\'Brien made it so.', + }, + }, + }, + }, + }, + ], + }, +}; + +/** + * Assert that the generated code matches the golden code for the specified + * language. + * @param {string} suffix The suffix of the golden file. + * @param {string} generated The generated code to compare against the + * golden file. + */ +function checkResult(suffix, generated) { + const fileName = `test/golden/golden.${suffix}`; + const goldenContents = fs.readFileSync(fileName); + // Normalize the line feeds. + const normalized = goldenContents.toString().replace(/(?:\r\n|\r|\n)/g, '\n'); + assert.equal(generated, normalized); +} + +suite('Multiline Text Block Generators', function () { + suiteSetup(function () { + Blockly.setLocale(en); + installAllBlocks({ + javascript: javascriptGenerator, + dart: dartGenerator, + lua: luaGenerator, + python: pythonGenerator, + php: phpGenerator, + }); + }); + setup(function () { + this.workspace = new Blockly.Workspace(); + Blockly.serialization.workspaces.load(blockJson, this.workspace); + }); + test('JavaScript', function () { + const generated = javascriptGenerator.workspaceToCode(this.workspace); + checkResult('js', generated); + }); + test('Dart', function () { + const generated = dartGenerator.workspaceToCode(this.workspace); + checkResult('dart', generated); + }); + test('Lua', function () { + const generated = luaGenerator.workspaceToCode(this.workspace); + checkResult('lua', generated); + }); + test('Python', function () { + const generated = pythonGenerator.workspaceToCode(this.workspace); + checkResult('py', generated); + }); + test('PHP', function () { + const generated = phpGenerator.workspaceToCode(this.workspace); + checkResult('php', generated); + }); + teardown(function () { + this.workspace.dispose(); + }); +}); diff --git a/packages/plugins/field-multilineinput/test/field_multilineinput_test.mocha.js b/packages/plugins/field-multilineinput/test/field_multilineinput_test.mocha.js new file mode 100644 index 00000000000..ce46e94d1a2 --- /dev/null +++ b/packages/plugins/field-multilineinput/test/field_multilineinput_test.mocha.js @@ -0,0 +1,300 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const {testHelpers} = require('@blockly/dev-tools'); +const { + FieldMultilineInput, + registerFieldMultilineInput, +} = require('../src/index'); +const Blockly = require('blockly'); +const {assert} = require('chai'); +const sinon = require('sinon'); + +const { + assertFieldValue, + FieldCreationTestCase, + FieldValueTestCase, + runConstructorSuiteTests, + runFromJsonSuiteTests, + runSetValueTests, +} = testHelpers; + +suite('FieldMultilineInput', function () { + setup(function () { + registerFieldMultilineInput(); + }); + /** + * Configuration for field tests with invalid values. + * @type {Array} + */ + const invalidValueTestCases = [ + {title: 'Undefined', value: undefined}, + {title: 'Null', value: null}, + ]; + /** + * Configuration for field tests with valid values. + * @type {Array} + */ + const validValueTestCases = [ + {title: 'Empty string', value: '', expectedValue: ''}, + {title: 'String no newline', value: 'value', expectedValue: 'value'}, + { + title: 'String with newline', + value: 'bark bark\n bark bark bark\n bark bar bark bark\n', + expectedValue: 'bark bark\n bark bark bark\n bark bar bark bark\n', + }, + {title: 'Boolean true', value: true, expectedValue: 'true'}, + {title: 'Boolean false', value: false, expectedValue: 'false'}, + {title: 'Number (Truthy)', value: 1, expectedValue: '1'}, + {title: 'Number (Falsy)', value: 0, expectedValue: '0'}, + {title: 'NaN', value: NaN, expectedValue: 'NaN'}, + ]; + const addArgsAndJson = function (testCase) { + testCase.args = [testCase.value]; + testCase.json = {text: testCase.value}; + }; + invalidValueTestCases.forEach(addArgsAndJson); + validValueTestCases.forEach(addArgsAndJson); + + /** + * The expected default value for the field being tested. + * @type {*} + */ + const defaultFieldValue = ''; + /** + * Asserts that the field property values are set to default. + * @param {!FieldMultilineInput} field The field to check. + */ + const assertFieldDefault = function (field) { + assertFieldValue(field, defaultFieldValue); + }; + + /** + * Asserts that the field properties are correct based on the test case. + * @param {FieldMultilineInput} field The field to check. + * @param {FieldValueTestCase} testCase The test case. + */ + const validTestCaseAssertField = function (field, testCase) { + assertFieldValue(field, testCase.expectedValue); + }; + + runConstructorSuiteTests( + FieldMultilineInput, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + runFromJsonSuiteTests( + FieldMultilineInput, + validValueTestCases, + invalidValueTestCases, + validTestCaseAssertField, + assertFieldDefault, + ); + + suite('setValue', function () { + suite('Empty -> New Value', function () { + setup(function () { + this.field = new FieldMultilineInput(); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + defaultFieldValue, + ); + }); + suite('Value -> New Value', function () { + const initialValue = 'oldValue'; + setup(function () { + this.field = new FieldMultilineInput(initialValue); + }); + runSetValueTests( + validValueTestCases, + invalidValueTestCases, + initialValue, + ); + }); + }); + + suite('Keyboard behavior', function () { + /** + * Dispatches a keydown event on the editor's textarea, setting the + * cursor/selection beforehand. + * @param {!HTMLTextAreaElement} textarea The editor's textarea. + * @param {!Object} options KeyboardEvent options (key, shiftKey, etc.). + * @param {number} [selectionStart] Optional selection start to set first. + * @param {number} [selectionEnd] Optional selection end to set first. + */ + const pressKey = function ( + textarea, + options, + selectionStart, + selectionEnd, + ) { + if (selectionStart !== undefined) { + textarea.selectionStart = selectionStart; + textarea.selectionEnd = selectionEnd ?? selectionStart; + } + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ...options, + }), + ); + }; + + setup(function () { + this.jsdomCleanup = require('jsdom-global')( + '
', + ); + // See https://github.com/RaspberryPiFoundation/blockly-samples/issues/2528. + global.SVGElement = window.SVGElement; + // Blockly's focus handling constructs FocusEvent, which jsdom exposes on + // window but not as a global. + global.FocusEvent = window.FocusEvent; + // jsdom doesn't provide requestAnimationFrame/cancelAnimationFrame, which + // block rendering relies on. Route them through (faked) timers so they + // exist and can be flushed deterministically in teardown. + this.clock = sinon.useFakeTimers(); + window.requestAnimationFrame = (cb) => setTimeout(cb, 0); + window.cancelAnimationFrame = (id) => clearTimeout(id); + this.workspace = Blockly.inject('blocklyDiv'); + + if (!Blockly.Blocks['multiline_block']) { + Blockly.defineBlocksWithJsonArray([ + { + type: 'multiline_block', + message0: '%1', + args0: [ + {type: 'field_multilinetext', name: 'FIELD', text: 'hello'}, + ], + }, + ]); + } + this.block = this.workspace.newBlock('multiline_block'); + this.block.initSvg(); + this.block.render(); + this.field = this.block.getField('FIELD'); + + // Open the editor so we exercise the real