feat!: Allow moving blocks, comments and bubbles using the keyboard (#9593)

* refactor!: Update dragging APIs.

* fix: Fix bug that caused drags to always result in deletion

* refactor: Clean up block drag handling with new API

* chore: Format files

* feat: Add an `isBoundedElement` type predicate

* feat: Make `Bubble` implement `IBoundedElement`

* fix: Fix jumping/scrolling when moving blocks

* feat: Add a `KeyboardMover`

* feat: Update the `BlockDragStrategy` to support constrained movement

* feat: Register keyboard shortcuts to drive movement

* feat: Display a move indicator on items that are being moved

* fix: Reenable move hints

* fix: Fix bugs that caused elements to be mispositioned by keyboard moves at non-default zoom levels

* fix: Fix a bug that caused certain connections to be visited out of order

* fix: Fix a bug that caused blocks to become disconnected during constrained moves

* test: Add tests for keyboard-driven movement

* chore: Add exports

* chore: Run formatter

* chore: Make the linter happy

* chore: Update closure compiler

* fix: Fix test suite on non-macOS

* fix: Don't scroll in response to arrow keys while moving items

* fix: Fix positioning of move indicator in RTL

* refactor: Clarify return types of drag-start related methods

* refactor: Make the `KeyboardMover` a singleton

* fix: Fix import path

* refactor: Remove `WorkspaceSvg.keyboardMoveInProgress`

* fix: Fix tests

* chore: Remove unused import

* chore: Clean up comments and names

* refactor: Make `IDraggable` extend `IBoundedElement` and `ISelectable`

* chore: Rename test blocks file for move mode

* refactor: Make block connection offset a constant

* refactor: Export `KeyboardMover` class with a static instance

* fix: Use Command and Control as modifiers for unconstrained move mode

* fix: Fix test failures in CI

* feat: Support allowlisting keyboard shortcuts for mid-move use
This commit is contained in:
Aaron Dodson
2026-03-03 11:51:07 -08:00
committed by GitHub
parent 81c2ed6ed1
commit da1db45dd2
27 changed files with 2959 additions and 290 deletions
+45 -86
View File
@@ -42,14 +42,14 @@
}
},
"node_modules/@commitlint/cli": {
"version": "20.4.1",
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.1.tgz",
"integrity": "sha512-uuFKKpc7OtQM+6SRqT+a4kV818o1pS+uvv/gsRhyX7g4x495jg+Q7P0+O9VNGyLXBYP0syksS7gMRDJKcekr6A==",
"version": "20.4.2",
"resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.2.tgz",
"integrity": "sha512-YjYSX2yj/WsVoxh9mNiymfFS2ADbg2EK4+1WAsMuckwKMCqJ5PDG0CJU/8GvmHWcv4VRB2V02KqSiecRksWqZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@commitlint/format": "^20.4.0",
"@commitlint/lint": "^20.4.1",
"@commitlint/lint": "^20.4.2",
"@commitlint/load": "^20.4.0",
"@commitlint/read": "^20.4.0",
"@commitlint/types": "^20.4.0",
@@ -64,9 +64,9 @@
}
},
"node_modules/@commitlint/config-conventional": {
"version": "20.4.1",
"resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.1.tgz",
"integrity": "sha512-0YUvIeBtpi86XriqrR+TCULVFiyYTIOEPjK7tTRMxjcBm1qlzb+kz7IF2WxL6Fq5DaundG8VO37BNgMkMTBwqA==",
"version": "20.4.2",
"resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.2.tgz",
"integrity": "sha512-rwkTF55q7Q+6dpSKUmJoScV0f3EpDlWKw2UPzklkLS4o5krMN1tPWAVOgHRtyUTMneIapLeQwaCjn44Td6OzBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -148,15 +148,15 @@
}
},
"node_modules/@commitlint/lint": {
"version": "20.4.1",
"resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.1.tgz",
"integrity": "sha512-g94LrGl/c6UhuhDQqNqU232aslLEN2vzc7MPfQTHzwzM4GHNnEAwVWWnh0zX8S5YXecuLXDwbCsoGwmpAgPWKA==",
"version": "20.4.2",
"resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.2.tgz",
"integrity": "sha512-buquzNRtFng6xjXvBU1abY/WPEEjCgUipNQrNmIWe8QuJ6LWLtei/LDBAzEe5ASm45+Q9L2Xi3/GVvlj50GAug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@commitlint/is-ignored": "^20.4.1",
"@commitlint/parse": "^20.4.1",
"@commitlint/rules": "^20.4.1",
"@commitlint/rules": "^20.4.2",
"@commitlint/types": "^20.4.0"
},
"engines": {
@@ -245,9 +245,9 @@
}
},
"node_modules/@commitlint/rules": {
"version": "20.4.1",
"resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.1.tgz",
"integrity": "sha512-WtqypKEPbQEuJwJS4aKs0OoJRBKz1HXPBC9wRtzVNH68FLhPWzxXlF09hpUXM9zdYTpm4vAdoTGkWiBgQ/vL0g==",
"version": "20.4.2",
"resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.2.tgz",
"integrity": "sha512-oz83pnp5Yq6uwwTAabuVQPNlPfeD2Y5ZjMb7Wx8FSUlu4sLYJjbBWt8031Z0osCFPfHzAwSYrjnfDFKtuSMdKg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -316,10 +316,21 @@
"node": "20 || >=22"
}
},
"node_modules/@simple-libs/stream-utils": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
"integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://ko-fi.com/dangreen"
}
},
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -335,8 +346,6 @@
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -345,8 +354,6 @@
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -361,8 +368,6 @@
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
@@ -405,8 +410,6 @@
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -415,6 +418,8 @@
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -426,8 +431,6 @@
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -449,8 +452,6 @@
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -462,8 +463,6 @@
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
@@ -479,9 +478,9 @@
}
},
"node_modules/conventional-changelog-angular": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.1.0.tgz",
"integrity": "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w==",
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.2.0.tgz",
"integrity": "sha512-4YB1zEXqB17oBI8yRsAs1T+ZhbdsOgJqkl6Trz+GXt/eKf1e4jnA0oW+sOd9BEENzEViuNW0DNoFFjSf3CeC5Q==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -492,9 +491,9 @@
}
},
"node_modules/conventional-changelog-conventionalcommits": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.1.0.tgz",
"integrity": "sha512-MnbEysR8wWa8dAEvbj5xcBgJKQlX/m0lhS8DsyAAWDHdfs2faDJxTgzRYlRYpXSe7UiKrIIlB4TrBKU9q9DgkA==",
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.2.0.tgz",
"integrity": "sha512-fCf+ODjseueTV09wVBoC0HXLi3OyuBJ+HfE3L63Khxqnr99f9nUcnQh3a15lCWHlGLihyZShW/mVVkBagr9JvQ==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -505,12 +504,13 @@
}
},
"node_modules/conventional-commits-parser": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.2.1.tgz",
"integrity": "sha512-20pyHgnO40rvfI0NGF/xiEoFMkXDtkF8FwHvk5BokoFoCuTQRI8vrNCNFWUOfuolKJMm1tPCHc8GgYEtr1XRNA==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.3.0.tgz",
"integrity": "sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@simple-libs/stream-utils": "^1.2.0",
"meow": "^13.0.0"
},
"bin": {
@@ -521,9 +521,9 @@
}
},
"node_modules/cosmiconfig": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz",
"integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -593,8 +593,6 @@
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
@@ -620,8 +618,6 @@
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -638,8 +634,6 @@
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
@@ -650,8 +644,6 @@
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"dev": true,
"funding": [
{
@@ -667,8 +659,6 @@
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
@@ -679,6 +669,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz",
"integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==",
"deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -831,8 +822,6 @@
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -848,8 +837,6 @@
},
"node_modules/import-fresh/node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -858,8 +845,6 @@
},
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
"integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -886,8 +871,6 @@
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -906,8 +889,6 @@
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -936,8 +917,6 @@
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -956,8 +935,6 @@
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
@@ -1068,8 +1045,6 @@
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1135,8 +1110,6 @@
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1145,8 +1118,6 @@
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1204,8 +1175,6 @@
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
@@ -1224,8 +1193,6 @@
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1239,8 +1206,6 @@
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1278,6 +1243,8 @@
},
"node_modules/vinyl": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz",
"integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1300,8 +1267,6 @@
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1318,8 +1283,6 @@
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
@@ -1328,8 +1291,6 @@
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1347,8 +1308,6 @@
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
+20 -9
View File
@@ -43,7 +43,11 @@ import type {IBoundedElement} from './interfaces/i_bounded_element.js';
import {IContextMenu} from './interfaces/i_contextmenu.js';
import type {ICopyable} from './interfaces/i_copyable.js';
import {IDeletable} from './interfaces/i_deletable.js';
import type {IDragStrategy, IDraggable} from './interfaces/i_draggable.js';
import type {
DragDisposition,
IDragStrategy,
IDraggable,
} from './interfaces/i_draggable.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import {IIcon} from './interfaces/i_icon.js';
@@ -1810,18 +1814,21 @@ export class BlockSvg
}
/** Starts a drag on the block. */
startDrag(e?: PointerEvent): void {
this.dragStrategy.startDrag(e);
startDrag(e?: PointerEvent | KeyboardEvent) {
return this.dragStrategy.startDrag(e);
}
/** Drags the block to the given location. */
drag(newLoc: Coordinate, e?: PointerEvent): void {
drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void {
this.dragStrategy.drag(newLoc, e);
}
/** Ends the drag on the block. */
endDrag(e?: PointerEvent): void {
this.dragStrategy.endDrag(e);
endDrag(
e: PointerEvent | KeyboardEvent | undefined,
disposition: DragDisposition,
): void {
this.dragStrategy.endDrag(e, disposition);
}
/** Moves the block back to where it was at the start of a drag. */
@@ -1880,9 +1887,13 @@ export class BlockSvg
/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.select();
this.workspace.scrollBoundsIntoView(
this.getBoundingRectangleWithoutChildren(),
);
if (getFocusManager().getFocusedNode() !== this) {
renderManagement.finishQueuedRenders().then(() => {
this.workspace.scrollBoundsIntoView(
this.getBoundingRectangleWithoutChildren(),
);
});
}
}
/** See IFocusableNode.onNodeBlur. */
+12 -1
View File
@@ -118,6 +118,8 @@ import * as icons from './icons.js';
import {inject} from './inject.js';
import * as inputs from './inputs.js';
import {IFlyoutInflater} from './interfaces/i_flyout_inflater.js';
import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js';
import {MoveIndicator} from './keyboard_nav/move_indicator.js';
import {LabelFlyoutInflater} from './label_flyout_inflater.js';
import {SeparatorFlyoutInflater} from './separator_flyout_inflater.js';
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
@@ -125,7 +127,10 @@ import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';
import {Input} from './inputs/input.js';
import {InsertionMarkerPreviewer} from './insertion_marker_previewer.js';
import {IAutoHideable} from './interfaces/i_autohideable.js';
import {IBoundedElement} from './interfaces/i_bounded_element.js';
import {
IBoundedElement,
isBoundedElement,
} from './interfaces/i_bounded_element.js';
import {IBubble} from './interfaces/i_bubble.js';
import {ICollapsibleToolboxItem} from './interfaces/i_collapsible_toolbox_item.js';
import {IComponent} from './interfaces/i_component.js';
@@ -137,6 +142,7 @@ import {IDeletable, isDeletable} from './interfaces/i_deletable.js';
import {IDeleteArea} from './interfaces/i_delete_area.js';
import {IDragTarget} from './interfaces/i_drag_target.js';
import {
DragDisposition,
IDragStrategy,
IDraggable,
isDraggable,
@@ -500,6 +506,8 @@ export {
BlockFlyoutInflater,
ButtonFlyoutInflater,
CodeGenerator,
Direction,
DragDisposition,
Field,
FieldCheckbox,
FieldCheckboxConfig,
@@ -584,6 +592,7 @@ export {
ImageProperties,
Input,
InsertionMarkerPreviewer,
KeyboardMover,
KeyboardNavigationController,
LabelFlyoutInflater,
LayerManager,
@@ -595,6 +604,7 @@ export {
MenuItem,
MenuOption,
MetricsManager,
MoveIndicator,
Msg,
Names,
Options,
@@ -626,6 +636,7 @@ export {
icons,
inject,
inputs,
isBoundedElement,
isCopyable,
isDeletable,
isDraggable,
+33 -3
View File
@@ -8,6 +8,7 @@ import * as browserEvents from '../browser_events.js';
import * as common from '../common.js';
import {BubbleDragStrategy} from '../dragging/bubble_drag_strategy.js';
import {getFocusManager} from '../focus_manager.js';
import {IBoundedElement} from '../interfaces/i_bounded_element.js';
import {IBubble} from '../interfaces/i_bubble.js';
import type {IFocusableNode} from '../interfaces/i_focusable_node.js';
import type {IFocusableTree} from '../interfaces/i_focusable_tree.js';
@@ -29,7 +30,9 @@ import {WorkspaceSvg} from '../workspace_svg.js';
* bubble, where it has a "tail" that points to the block, and a "head" that
* displays arbitrary svg elements.
*/
export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
export abstract class Bubble
implements IBubble, ISelectable, IFocusableNode, IBoundedElement
{
/** The width of the border around the bubble. */
static readonly BORDER_WIDTH = 6;
@@ -274,6 +277,18 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
this.svgRoot.setAttribute('transform', `translate(${x}, ${y})`);
}
/**
* Moves the bubble by the given amounts in the x and y directions.
*
* @param dx The distance to move along the x axis.
* @param dy The distance to move along the y axis.
* @param _reason A description of why this move is happening.
*/
moveBy(dx: number, dy: number, _reason?: string[]) {
const origin = this.getRelativeToSurfaceXY();
this.moveTo(origin.x + dx, origin.y + dy);
}
/**
* Positions the bubble "optimally" so that the most of it is visible and
* it does not overlap the rect (if provided).
@@ -617,6 +632,21 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
);
}
/**
* Returns the bounds of this bubble.
*
* @returns A bounding box for this bubble.
*/
getBoundingRectangle(): Rect {
const origin = this.getRelativeToSurfaceXY();
return new Rect(
origin.y,
origin.y + this.size.height,
origin.x,
origin.x + this.size.width,
);
}
/** @internal */
getSvgRoot(): SVGElement {
return this.svgRoot;
@@ -664,8 +694,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {
}
/** Starts a drag on the bubble. */
startDrag(): void {
this.dragStrategy.startDrag();
startDrag() {
return this.dragStrategy.startDrag();
}
/** Drags the bubble to the given location. */
@@ -6,6 +6,7 @@
import type {BlocklyOptions} from '../blockly_options.js';
import {Abstract as AbstractEvent} from '../events/events_abstract.js';
import {KeyboardMover} from '../keyboard_nav/keyboard_mover.js';
import {Options} from '../options.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
@@ -153,11 +154,10 @@ export class MiniWorkspaceBubble extends Bubble {
* are dealt with by resizing the workspace to show them.
*/
private bumpBlocksIntoBounds() {
if (
this.miniWorkspace.isDragging() &&
!this.miniWorkspace.keyboardMoveInProgress
)
// Only bump for mouse-driven drags.
if (this.miniWorkspace.isDragging() && !KeyboardMover.mover.isMoving()) {
return;
}
const MARGIN = 20;
@@ -189,15 +189,13 @@ export class MiniWorkspaceBubble extends Bubble {
* mini workspace.
*/
private updateBubbleSize() {
if (
this.miniWorkspace.isDragging() &&
!this.miniWorkspace.keyboardMoveInProgress
)
if (this.miniWorkspace.isDragging() && !KeyboardMover.mover.isMoving()) {
return;
}
// Disable autolayout if a keyboard move is in progress to prevent the
// mutator bubble from jumping around.
this.autoLayout &&= !this.miniWorkspace.keyboardMoveInProgress;
this.autoLayout &&= !KeyboardMover.mover.isMoving();
const currSize = this.getSize();
const newSize = this.calculateWorkspaceSize();
@@ -239,8 +239,8 @@ export class RenderedWorkspaceComment
}
/** Starts a drag on the comment. */
startDrag(): void {
this.dragStrategy.startDrag();
startDrag() {
return this.dragStrategy.startDrag();
}
/** Drags the comment to the given location. */
@@ -221,7 +221,7 @@ export class WorkspaceComment {
/** Returns the position of the comment in workspace coordinates. */
getRelativeToSurfaceXY(): Coordinate {
return this.location;
return this.location.clone();
}
/** Disposes of this comment. */
@@ -6,7 +6,7 @@
import type {Block} from '../block.js';
import * as blockAnimation from '../block_animations.js';
import {BlockSvg} from '../block_svg.js';
import type {BlockSvg} from '../block_svg.js';
import * as bumpObjects from '../bump_objects.js';
import {config} from '../config.js';
import {Connection} from '../connection.js';
@@ -14,17 +14,20 @@ import {ConnectionType} from '../connection_type.js';
import type {BlockMove} from '../events/events_block_move.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {showUnconstrainedMoveHint} from '../hints.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js';
import {IDragStrategy} from '../interfaces/i_draggable.js';
import type {IConnectionPreviewer} from '../interfaces/i_connection_previewer.js';
import type {IDragStrategy} from '../interfaces/i_draggable.js';
import {DragDisposition} from '../interfaces/i_draggable.js';
import {IHasBubble, hasBubble} from '../interfaces/i_has_bubble.js';
import {Direction} from '../keyboard_nav/keyboard_mover.js';
import * as layers from '../layers.js';
import * as registry from '../registry.js';
import {finishQueuedRenders} from '../render_management.js';
import {RenderedConnection} from '../rendered_connection.js';
import type {RenderedConnection} from '../rendered_connection.js';
import {Coordinate} from '../utils.js';
import * as dom from '../utils/dom.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
/** Represents a nearby valid connection. */
interface ConnectionCandidate {
@@ -38,6 +41,16 @@ interface ConnectionCandidate {
distance: number;
}
/**
* Represents a block movement paradigm; constrained moves only to valid
* connections, while unconstrained allows free movement to anywhere on the
* workspace.
*/
enum MoveMode {
CONSTRAINED = 1,
UNCONSTRAINED = 2,
}
export class BlockDragStrategy implements IDragStrategy {
private workspace: WorkspaceSvg;
@@ -58,25 +71,26 @@ export class BlockDragStrategy implements IDragStrategy {
private dragging = false;
/**
* If this is a shadow block, the offset between this block and the parent
* block, to add to the drag location. In workspace units.
*/
private dragOffset = new Coordinate(0, 0);
/** Where a constrained movement should start when traversing the tree. */
private searchNode: RenderedConnection | null = null;
/** List of all connections available on the workspace. */
private allConnections: RenderedConnection[] = [];
/** The current movement mode. */
private moveMode = MoveMode.UNCONSTRAINED;
/** Used to persist an event group when snapping is done async. */
private originalEventGroup = '';
protected readonly BLOCK_CONNECTION_OFFSET = 10;
constructor(private block: BlockSvg) {
this.workspace = block.workspace;
}
/** Returns true if the block is currently movable. False otherwise. */
isMovable(): boolean {
if (this.block.isShadow()) {
return this.block.getParent()?.isMovable() ?? false;
}
return (
this.block.isOwnMovable() &&
!this.block.isDeadOrDying() &&
@@ -91,12 +105,7 @@ export class BlockDragStrategy implements IDragStrategy {
* Handles any setup for starting the drag, including disconnecting the block
* from any parent blocks.
*/
startDrag(e?: PointerEvent): void {
if (this.block.isShadow()) {
this.startDraggingShadow(e);
return;
}
startDrag(e?: PointerEvent | KeyboardEvent) {
this.dragging = true;
this.fireDragStartEvent();
@@ -125,6 +134,40 @@ export class BlockDragStrategy implements IDragStrategy {
this.getVisibleBubbles(this.block).forEach((bubble) => {
this.workspace.getLayerManager()?.moveToDragLayer(bubble, false);
});
// For keyboard-driven moves, cache a list of valid connection points for
// use in constrained moved mode.
if (e instanceof KeyboardEvent) {
for (const topBlock of this.block.workspace.getTopBlocks(true)) {
this.allConnections.push(...this.getAllConnections(topBlock));
}
// Scooch the block to be offset from the connection preview indicator.
this.block.moveDuringDrag(this.startLoc);
this.connectionCandidate = this.createInitialCandidate();
const neighbour = this.updateConnectionPreview(
this.block,
new Coordinate(0, 0),
);
if (neighbour) {
let offset: Coordinate;
if (neighbour.type === ConnectionType.PREVIOUS_STATEMENT) {
const origin = this.block.getRelativeToSurfaceXY();
offset = new Coordinate(
origin.x + this.BLOCK_CONNECTION_OFFSET,
origin.y - this.BLOCK_CONNECTION_OFFSET,
);
} else {
offset = new Coordinate(
neighbour.x + this.BLOCK_CONNECTION_OFFSET,
neighbour.y + this.BLOCK_CONNECTION_OFFSET,
);
}
this.block.moveDuringDrag(offset);
}
}
return this.block;
}
/**
@@ -159,24 +202,10 @@ export class BlockDragStrategy implements IDragStrategy {
* @returns True if just the initial block should be dragged out, false
* if all following blocks should also be dragged.
*/
protected shouldHealStack(e: PointerEvent | undefined) {
return !!e && (e.altKey || e.ctrlKey || e.metaKey);
}
/** Starts a drag on a shadow, recording the drag offset. */
private startDraggingShadow(e?: PointerEvent) {
const parent = this.block.getParent();
if (!parent) {
throw new Error(
'Tried to drag a shadow block with no parent. ' +
'Shadow blocks should always have parents.',
);
}
this.dragOffset = Coordinate.difference(
parent.getRelativeToSurfaceXY(),
this.block.getRelativeToSurfaceXY(),
);
parent.startDrag(e);
protected shouldHealStack(e: PointerEvent | KeyboardEvent | undefined) {
return e instanceof PointerEvent
? e.ctrlKey || e.metaKey
: !!this.block.previousConnection;
}
/**
@@ -246,25 +275,62 @@ export class BlockDragStrategy implements IDragStrategy {
}
/** Moves the block and updates any connection previews. */
drag(newLoc: Coordinate): void {
if (this.block.isShadow()) {
this.block.getParent()?.drag(Coordinate.sum(newLoc, this.dragOffset));
return;
}
drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void {
this.moveMode =
e instanceof KeyboardEvent && !(e.ctrlKey || e.metaKey)
? MoveMode.CONSTRAINED
: MoveMode.UNCONSTRAINED;
this.block.moveDuringDrag(newLoc);
if (this.moveMode === MoveMode.UNCONSTRAINED) {
this.block.moveDuringDrag(newLoc);
}
this.updateConnectionPreview(
this.block,
Coordinate.difference(newLoc, this.startLoc!),
);
// Handle the case where the drag has reached a possible connection.
if (this.connectionCandidate) {
const neighbour = this.connectionCandidate.neighbour;
// The next constrained move will resume the search from the current
// candidate location.
this.searchNode = neighbour;
if (this.moveMode === MoveMode.CONSTRAINED) {
// Position the moving block down and slightly to the right of the
// target connection.
this.block.moveDuringDrag(
new Coordinate(
neighbour.x + this.BLOCK_CONNECTION_OFFSET,
neighbour.y + this.BLOCK_CONNECTION_OFFSET,
),
);
}
} else {
// No connection was available or adequately close to the dragged block;
// clear out the search node since we have nowhere to search from, and
// suggest using unconstrained mode to arbitrarily position the block if
// we're in keyboard-driven constrained mode.
this.searchNode = null;
if (this.moveMode === MoveMode.CONSTRAINED) {
showUnconstrainedMoveHint(this.workspace, true);
}
}
}
/**
* Renders the connection preview indicator.
*
* @param draggingBlock The block being dragged.
* @param delta How far the pointer has moved from the position
* at the start of the drag, in workspace units.
* @returns The neighbouring connection to which the connection preview will
* be attached.
*/
private updateConnectionPreview(draggingBlock: BlockSvg, delta: Coordinate) {
private updateConnectionPreview(
draggingBlock: BlockSvg,
delta: Coordinate,
): RenderedConnection | undefined {
const currCandidate = this.connectionCandidate;
const newCandidate = this.getConnectionCandidate(draggingBlock, delta);
if (!newCandidate) {
@@ -299,9 +365,10 @@ export class BlockDragStrategy implements IDragStrategy {
neighbour,
neighbour.targetBlock()!,
);
return;
} else {
this.connectionPreviewer?.previewConnection(local, neighbour);
}
this.connectionPreviewer?.previewConnection(local, neighbour);
return neighbour;
}
/**
@@ -333,6 +400,9 @@ export class BlockDragStrategy implements IDragStrategy {
delta: Coordinate,
newCandidate: ConnectionCandidate,
): boolean {
// New connection is always better during a constrained move.
if (this.moveMode === MoveMode.CONSTRAINED) return false;
const {local: currLocal, neighbour: currNeighbour} = currCandiate;
const localPos = new Coordinate(currLocal.x, currLocal.y);
const neighbourPos = new Coordinate(currNeighbour.x, currNeighbour.y);
@@ -356,9 +426,26 @@ export class BlockDragStrategy implements IDragStrategy {
delta: Coordinate,
): ConnectionCandidate | null {
const localConns = this.getLocalConnections(draggingBlock);
let radius = this.getSearchRadius();
let candidate = null;
if (this.moveMode === MoveMode.CONSTRAINED) {
const direction = this.getDirectionToNewLocation(
Coordinate.sum(this.startLoc!, delta),
);
candidate = this.findTraversalCandidate(
draggingBlock,
localConns,
direction,
);
if (candidate) {
return candidate;
}
delta = new Coordinate(0, 0);
}
let radius = this.getSearchRadius();
for (const conn of localConns) {
const {connection: neighbour, radius: rad} = conn.closest(radius, delta);
if (neighbour) {
@@ -378,6 +465,8 @@ export class BlockDragStrategy implements IDragStrategy {
* Get the radius to use when searching for a nearby valid connection.
*/
protected getSearchRadius() {
if (this.moveMode === MoveMode.CONSTRAINED) return Infinity;
return this.connectionCandidate
? config.connectingSnapRadius
: config.snapRadius;
@@ -402,11 +491,14 @@ export class BlockDragStrategy implements IDragStrategy {
* Cleans up any state at the end of the drag. Applies any pending
* connections.
*/
endDrag(e?: PointerEvent): void {
if (this.block.isShadow()) {
this.block.getParent()?.endDrag(e);
return;
endDrag(
_e: PointerEvent | KeyboardEvent | undefined,
disposition: DragDisposition,
): void {
if (disposition === DragDisposition.DELETE) {
blockAnimation.disposeUiEffect(this.block);
}
this.originalEventGroup = eventUtils.getGroup();
this.fireDragEndEvent();
@@ -440,6 +532,8 @@ export class BlockDragStrategy implements IDragStrategy {
} else {
this.block.queueRender().then(() => this.disposeStep());
}
this.allConnections = [];
}
/** Disposes of any state at the end of the drag. */
@@ -477,11 +571,6 @@ export class BlockDragStrategy implements IDragStrategy {
* including reconnecting connections.
*/
revertDrag(): void {
if (this.block.isShadow()) {
this.block.getParent()?.revertDrag();
return;
}
this.connectionPreviewer?.hidePreview();
this.connectionCandidate = null;
@@ -520,4 +609,171 @@ export class BlockDragStrategy implements IDragStrategy {
this.block.setDragging(false);
this.dragging = false;
}
/**
* Get the nearest valid candidate connection in traversal order.
*
* @param draggingBlock The root block being dragged.
* @param localConns The list of connections on the dragging block(s) that are
* available to connect to.
* @param direction The cardinal direction in which the block is being moved.
* @returns A candidate connection and radius, or null if none was found.
*/
findTraversalCandidate(
draggingBlock: BlockSvg,
localConns: RenderedConnection[],
direction: Direction,
): ConnectionCandidate | null {
const connectionChecker = draggingBlock.workspace.connectionChecker;
let candidateConnection: ConnectionCandidate | null = null;
let potential: RenderedConnection | null = this.searchNode;
while (potential && !candidateConnection) {
const potentialIndex = this.allConnections.indexOf(potential);
if (direction === Direction.UP || direction === Direction.LEFT) {
potential =
this.allConnections[potentialIndex - 1] ??
this.allConnections[this.allConnections.length - 1];
} else if (
direction === Direction.DOWN ||
direction === Direction.RIGHT
) {
potential =
this.allConnections[potentialIndex + 1] ?? this.allConnections[0];
}
localConns.forEach((conn: RenderedConnection) => {
if (
potential &&
connectionChecker.canConnect(conn, potential, true, Infinity) &&
!potential.targetBlock()?.isInsertionMarker()
) {
candidateConnection = {
local: conn,
neighbour: potential,
distance: 0,
};
}
});
if (potential == this.searchNode) break;
}
return candidateConnection;
}
/**
* Create a candidate representing where the block was previously connected.
* Used to render the block position after picking up the block but before
* moving during a drag.
*
* @returns A connection candidate representing where the block was at the
* start of the drag.
*/
private createInitialCandidate(): ConnectionCandidate | null {
this.searchNode = this.startParentConn ?? this.startChildConn;
switch (this.searchNode?.type) {
case ConnectionType.INPUT_VALUE: {
if (this.block.outputConnection) {
return {
neighbour: this.searchNode,
local: this.block.outputConnection,
distance: 0,
};
}
break;
}
case ConnectionType.NEXT_STATEMENT: {
if (this.block.previousConnection) {
return {
neighbour: this.searchNode,
local: this.block.previousConnection,
distance: 0,
};
}
break;
}
case ConnectionType.PREVIOUS_STATEMENT: {
if (this.block.nextConnection) {
return {
neighbour: this.searchNode,
local: this.block.nextConnection,
distance: 0,
};
}
break;
}
}
return null;
}
/**
* Returns the cardinal direction that the block being dragged would have to
* move in to reach the given location.
* The given coordinate should differ from the current location on only one
* axis.
*
* @param newLocation The intended destination for the block.
* @returns The direction the block would need to travel to reach the new
* location.
*/
private getDirectionToNewLocation(newLocation: Coordinate): Direction {
const actualPosition = this.block.getRelativeToSurfaceXY();
const delta = Coordinate.difference(newLocation, actualPosition);
const {x, y} = delta;
if (x) {
if (x < 0) {
return Direction.LEFT;
} else if (x > 0) {
return Direction.RIGHT;
}
} else if (y) {
if (y < 0) {
return Direction.UP;
} else if (y > 0) {
return Direction.DOWN;
}
}
return Direction.NONE;
}
/**
* Returns all navigable connections on the given block and its children.
* Omits connections on shadow blocks, collapsed blocks, or those that are
* associated with a hidden input.
*
* @param block The block to use as a starting point for retrieving
* connections.
* @returns All connections on the block and its children.
*/
private getAllConnections(block: BlockSvg): RenderedConnection[] {
if (block.isShadow()) return [];
const connections = [];
if (block.outputConnection) connections.push(block.outputConnection);
if (block.previousConnection) connections.push(block.previousConnection);
if (!block.isCollapsed()) {
for (const input of block.inputList) {
if (input.connection && input.isVisible()) {
connections.push(input.connection);
const target = input.connection.targetBlock() as BlockSvg;
if (target) {
connections.push(...this.getAllConnections(target));
}
}
}
}
if (block.nextConnection) {
connections.push(block.nextConnection);
const target = block.nextConnection.targetBlock() as BlockSvg;
if (target) {
connections.push(...this.getAllConnections(target));
}
}
return connections as RenderedConnection[];
}
}
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {IBubble, WorkspaceSvg} from '../blockly.js';
import {IDragStrategy} from '../interfaces/i_draggable.js';
import type {IBubble} from '../interfaces/i_bubble.js';
import type {IDragStrategy} from '../interfaces/i_draggable.js';
import * as layers from '../layers.js';
import {Coordinate} from '../utils.js';
import type {Coordinate} from '../utils.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
export class BubbleDragStrategy implements IDragStrategy {
private startLoc: Coordinate | null = null;
@@ -21,13 +22,15 @@ export class BubbleDragStrategy implements IDragStrategy {
return true;
}
startDrag(): void {
startDrag() {
this.startLoc = this.bubble.getRelativeToSurfaceXY();
this.workspace.setResizesEnabled(false);
this.workspace.getLayerManager()?.moveToDragLayer(this.bubble);
if (this.bubble.setDragging) {
this.bubble.setDragging(true);
}
return this.bubble;
}
drag(newLoc: Coordinate): void {
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {RenderedWorkspaceComment} from '../comments.js';
import {CommentMove} from '../events/events_comment_move.js';
import type {RenderedWorkspaceComment} from '../comments.js';
import type {CommentMove} from '../events/events_comment_move.js';
import {EventType} from '../events/type.js';
import * as eventUtils from '../events/utils.js';
import {IDragStrategy} from '../interfaces/i_draggable.js';
import type {IDragStrategy} from '../interfaces/i_draggable.js';
import * as layers from '../layers.js';
import {Coordinate} from '../utils.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import type {Coordinate} from '../utils.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
export class CommentDragStrategy implements IDragStrategy {
private startLoc: Coordinate | null = null;
@@ -30,12 +30,13 @@ export class CommentDragStrategy implements IDragStrategy {
);
}
startDrag(): void {
startDrag() {
this.fireDragStartEvent();
this.startLoc = this.comment.getRelativeToSurfaceXY();
this.workspace.setResizesEnabled(false);
this.workspace.getLayerManager()?.moveToDragLayer(this.comment);
this.comment.setDragging(true);
return this.comment;
}
drag(newLoc: Coordinate): void {
+70 -45
View File
@@ -4,20 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as blockAnimations from '../block_animations.js';
import {BlockSvg} from '../block_svg.js';
import {ComponentManager} from '../component_manager.js';
import * as eventUtils from '../events/utils.js';
import {getFocusManager} from '../focus_manager.js';
import {IDeletable, isDeletable} from '../interfaces/i_deletable.js';
import {IDeleteArea} from '../interfaces/i_delete_area.js';
import {IDragTarget} from '../interfaces/i_drag_target.js';
import {IDraggable} from '../interfaces/i_draggable.js';
import {IDragger} from '../interfaces/i_dragger.js';
import type {IDeletable} from '../interfaces/i_deletable.js';
import {isDeletable} from '../interfaces/i_deletable.js';
import type {IDeleteArea} from '../interfaces/i_delete_area.js';
import type {IDragTarget} from '../interfaces/i_drag_target.js';
import {DragDisposition, type IDraggable} from '../interfaces/i_draggable.js';
import type {IDragger} from '../interfaces/i_dragger.js';
import {isFocusableNode} from '../interfaces/i_focusable_node.js';
import * as registry from '../registry.js';
import {Coordinate} from '../utils/coordinate.js';
import {WorkspaceSvg} from '../workspace_svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
export class Dragger implements IDragger {
protected startLoc: Coordinate;
@@ -32,11 +31,14 @@ export class Dragger implements IDragger {
}
/** Handles any drag startup. */
onDragStart(e: PointerEvent) {
onDragStart(e?: PointerEvent | KeyboardEvent) {
if (!eventUtils.getGroup()) {
eventUtils.setGroup(true);
}
this.draggable.startDrag(e);
this.draggable = this.draggable.startDrag(e);
this.startLoc = this.draggable.getRelativeToSurfaceXY();
return this.draggable;
}
/**
@@ -45,27 +47,30 @@ export class Dragger implements IDragger {
* @param totalDelta The total amount in pixel coordinates the mouse has moved
* since the start of the drag.
*/
onDrag(e: PointerEvent, totalDelta: Coordinate) {
onDrag(e: PointerEvent | KeyboardEvent | undefined, totalDelta: Coordinate) {
this.moveDraggable(e, totalDelta);
const root = this.getRoot(this.draggable);
// Must check `wouldDelete` before calling other hooks on drag targets
// since we have documented that we would do so.
if (isDeletable(root)) {
root.setDeleteStyle(this.wouldDeleteDraggable(e, root));
if (isDeletable(this.draggable)) {
this.draggable.setDeleteStyle(
this.wouldDeleteDraggable(
this.draggable.getRelativeToSurfaceXY(),
this.draggable,
),
);
}
this.updateDragTarget(e);
this.updateDragTarget(this.draggable.getRelativeToSurfaceXY());
}
/** Updates the drag target under the pointer (if there is one). */
protected updateDragTarget(e: PointerEvent) {
const newDragTarget = this.workspace.getDragTarget(e);
const root = this.getRoot(this.draggable);
protected updateDragTarget(coordinate: Coordinate) {
const newDragTarget = this.workspace.getDragTarget(coordinate);
if (this.dragTarget !== newDragTarget) {
this.dragTarget?.onDragExit(root);
newDragTarget?.onDragEnter(root);
this.dragTarget?.onDragExit(this.draggable);
newDragTarget?.onDragEnter(this.draggable);
}
newDragTarget?.onDragOver(root);
newDragTarget?.onDragOver(this.draggable);
this.dragTarget = newDragTarget;
}
@@ -73,7 +78,10 @@ export class Dragger implements IDragger {
* Calculates the correct workspace coordinate for the movable and tells
* the draggable to go to that location.
*/
private moveDraggable(e: PointerEvent, totalDelta: Coordinate) {
private moveDraggable(
e: PointerEvent | KeyboardEvent | undefined,
totalDelta: Coordinate,
) {
const delta = this.pixelsToWorkspaceUnits(totalDelta);
const newLoc = Coordinate.sum(this.startLoc, delta);
this.draggable.drag(newLoc, e);
@@ -84,10 +92,10 @@ export class Dragger implements IDragger {
* at the current location.
*/
protected wouldDeleteDraggable(
e: PointerEvent,
coordinate: Coordinate,
rootDraggable: IDraggable & IDeletable,
) {
const dragTarget = this.workspace.getDragTarget(e);
const dragTarget = this.workspace.getDragTarget(coordinate);
if (!dragTarget) return false;
const componentManager = this.workspace.getComponentManager();
@@ -101,34 +109,46 @@ export class Dragger implements IDragger {
}
/** Handles any drag cleanup. */
onDragEnd(e: PointerEvent) {
onDragEnd(e?: PointerEvent | KeyboardEvent) {
const origGroup = eventUtils.getGroup();
const dragTarget = this.workspace.getDragTarget(e);
const root = this.getRoot(this.draggable);
const dragTarget = this.workspace.getDragTarget(
this.draggable.getRelativeToSurfaceXY(),
);
if (dragTarget) {
this.dragTarget?.onDrop(root);
this.dragTarget?.onDrop(this.draggable);
}
if (this.shouldReturnToStart(e, root)) {
let reverted = false;
if (
this.shouldReturnToStart(
this.draggable.getRelativeToSurfaceXY(),
this.draggable,
)
) {
reverted = true;
this.draggable.revertDrag();
}
const wouldDelete = isDeletable(root) && this.wouldDeleteDraggable(e, root);
const wouldDelete =
isDeletable(this.draggable) &&
this.wouldDeleteDraggable(
this.draggable.getRelativeToSurfaceXY(),
this.draggable,
);
// TODO(#8148): use a generalized API instead of an instanceof check.
if (wouldDelete && this.draggable instanceof BlockSvg) {
blockAnimations.disposeUiEffect(this.draggable.getRootBlock());
}
this.draggable.endDrag(e);
if (wouldDelete && isDeletable(root)) {
if (wouldDelete && isDeletable(this.draggable)) {
this.draggable.endDrag(e, DragDisposition.DELETE);
// We want to make sure the delete gets grouped with any possible move
// event. In core Blockly this shouldn't happen, but due to a change
// in behavior older custom draggables might still clear the group.
eventUtils.setGroup(origGroup);
root.dispose();
this.draggable.dispose();
} else {
this.draggable.endDrag(
e,
reverted ? DragDisposition.REVERT : DragDisposition.COMMIT,
);
}
eventUtils.setGroup(false);
@@ -139,18 +159,23 @@ export class Dragger implements IDragger {
}
}
// We need to special case blocks for now so that we look at the root block
// instead of the one actually being dragged in most cases.
private getRoot(draggable: IDraggable): IDraggable {
return draggable instanceof BlockSvg ? draggable.getRootBlock() : draggable;
/** Handles a drag being reverted. */
onDragRevert() {
this.draggable.revertDrag();
if (isFocusableNode(this.draggable)) {
getFocusManager().focusNode(this.draggable);
}
}
/**
* Returns true if we should return the draggable to its original location
* at the end of the drag.
*/
protected shouldReturnToStart(e: PointerEvent, rootDraggable: IDraggable) {
const dragTarget = this.workspace.getDragTarget(e);
protected shouldReturnToStart(
coordinate: Coordinate,
rootDraggable: IDraggable,
) {
const dragTarget = this.workspace.getDragTarget(coordinate);
if (!dragTarget) return false;
return dragTarget.shouldPreventMove(rootDraggable);
}
+64
View File
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import {Msg} from './msg.js';
import {Toast} from './toast.js';
import * as userAgent from './utils/useragent.js';
import type {WorkspaceSvg} from './workspace_svg.js';
const unconstrainedMoveHintId = 'unconstrainedMoveHint';
const constrainedMoveHintId = 'constrainedMoveHint';
/**
* Nudge the user to use unconstrained movement.
*
* @param workspace Workspace.
* @param force Set to show it even if previously shown.
*/
export function showUnconstrainedMoveHint(
workspace: WorkspaceSvg,
force = false,
) {
const modifier =
userAgent.MAC || userAgent.IPAD || userAgent.IPHONE
? Msg['COMMAND_KEY']
: Msg['CONTROL_KEY'];
const message = Msg['KEYBOARD_NAV_UNCONSTRAINED_MOVE_HINT']
.replace('%1', modifier)
.replace('%2', Msg['ENTER_KEY']);
Toast.show(workspace, {
message,
id: unconstrainedMoveHintId,
oncePerSession: !force,
});
}
/**
* Nudge the user to move a block that's in move mode.
*
* @param workspace Workspace.
*/
export function showConstrainedMovementHint(workspace: WorkspaceSvg) {
const message = Msg['KEYBOARD_NAV_CONSTRAINED_MOVE_HINT'].replace(
'%1',
Msg['ENTER_KEY'],
);
Toast.show(workspace, {
message,
id: constrainedMoveHintId,
oncePerSession: true,
});
}
/**
* Clear active move-related hints, if any.
*
* @param workspace The workspace.
*/
export function clearMoveHints(workspace: WorkspaceSvg) {
Toast.hide(workspace, constrainedMoveHintId);
Toast.hide(workspace, unconstrainedMoveHintId);
}
@@ -29,3 +29,16 @@ export interface IBoundedElement {
*/
moveBy(dx: number, dy: number, reason?: string[]): void;
}
/**
* Returns whether or not the given object conforms to IBoundedElement.
*
* @param object The object to test for conformance.
* @returns True if the object conforms to IBoundedElement, otherwise false.
*/
export function isBoundedElement(object: any): object is IBoundedElement {
return (
typeof (object as IBoundedElement).getBoundingRectangle === 'function' &&
typeof (object as IBoundedElement).moveBy === 'function'
);
}
+7 -2
View File
@@ -7,14 +7,19 @@
// Former goog.module ID: Blockly.IBubble
import type {Coordinate} from '../utils/coordinate.js';
import {IBoundedElement} from './i_bounded_element.js';
import type {IContextMenu} from './i_contextmenu.js';
import type {IDraggable} from './i_draggable.js';
import {IFocusableNode} from './i_focusable_node.js';
import {ISelectable} from './i_selectable.js';
/**
* A bubble interface.
*/
export interface IBubble extends IDraggable, IContextMenu, IFocusableNode {
export interface IBubble
extends IDraggable,
IContextMenu,
ISelectable,
IBoundedElement {
/**
* Return the coordinates of the top-left corner of this bubble's body
* relative to the drawing surface's origin (0,0), in workspace units.
+29 -17
View File
@@ -4,15 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Coordinate} from '../utils/coordinate';
import type {Coordinate} from '../utils/coordinate.js';
import type {IBoundedElement} from './i_bounded_element.js';
import type {ISelectable} from './i_selectable.js';
export enum DragDisposition {
COMMIT = 1,
DELETE = 2,
REVERT = 3,
}
/**
* Represents an object that can be dragged.
*/
export interface IDraggable extends IDragStrategy {
export interface IDraggable
extends IDragStrategy,
IBoundedElement,
ISelectable {
/**
* Returns the current location of the draggable in workspace
* coordinates.
* Returns the current location of the draggable in workspace coordinates.
*
* @returns Coordinate of current location on workspace.
*/
@@ -27,11 +37,11 @@ export interface IDragStrategy {
* Handles any drag startup (e.g moving elements to the front of the
* workspace).
*
* @param e PointerEvent that started the drag; can be used to
* check modifier keys, etc. May be missing when dragging is
* triggered programatically rather than by user.
* @param e Event that started the drag; can be used to check modifier keys,
* etc. May be missing when dragging is triggered programmatically rather
* than by user.
*/
startDrag(e?: PointerEvent): void;
startDrag(e?: PointerEvent | KeyboardEvent): IDraggable;
/**
* Handles moving elements to the new location, and updating any
@@ -39,21 +49,23 @@ export interface IDragStrategy {
*
* @param newLoc Workspace coordinate to which the draggable has
* been dragged.
* @param e PointerEvent that continued the drag. Can be
* used to check modifier keys, etc.
* @param e Event that continued the drag. Can be used to check modifier
* keys, etc.
*/
drag(newLoc: Coordinate, e?: PointerEvent): void;
drag(newLoc: Coordinate, e?: PointerEvent | KeyboardEvent): void;
/**
* Handles any drag cleanup, including e.g. connecting or deleting
* blocks.
* Handles any drag cleanup, including e.g. connecting or deleting blocks.
*
* @param newLoc Workspace coordinate at which the drag finished.
* been dragged.
* @param e PointerEvent that finished the drag. Can be
* used to check modifier keys, etc.
* @param e Event that finished the drag. Can be used to check modifier keys,
* etc.
* @param disposition The end result of the drag.
*/
endDrag(e?: PointerEvent): void;
endDrag(
e: PointerEvent | KeyboardEvent | undefined,
disposition: DragDisposition,
): void;
/** Moves the draggable back to where it was at the start of the drag. */
revertDrag(): void;
+29 -10
View File
@@ -4,32 +4,51 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Coordinate} from '../utils/coordinate';
import type {Coordinate} from '../utils/coordinate';
import type {IDraggable} from './i_draggable';
export interface IDragger {
/**
* Handles any drag startup.
*
* @param e PointerEvent that started the drag.
* @param e Event that started the drag.
*/
onDragStart(e: PointerEvent): void;
onDragStart(e?: PointerEvent | KeyboardEvent): IDraggable;
/**
* Handles dragging, including calculating where the element should
* actually be moved to.
*
* @param e PointerEvent that continued the drag.
* @param totalDelta The total distance, in pixels, that the mouse
* @param e Event that continued the drag.
* @param totalDelta The total distance, in pixels, that the draggable
* has moved since the start of the drag.
*/
onDrag(e: PointerEvent, totalDelta: Coordinate): void;
onDrag(
e: PointerEvent | KeyboardEvent | undefined,
totalDelta: Coordinate,
): void;
/**
* Handles any drag cleanup.
* Handles any drag cleanup when a drag finishes normally.
*
* @param e PointerEvent that finished the drag.
* @param totalDelta The total distance, in pixels, that the mouse
* @param e Event that finished the drag.
* @param totalDelta The total distance, in pixels, that the draggable
* has moved since the start of the drag.
*/
onDragEnd(e: PointerEvent, totalDelta: Coordinate): void;
onDragEnd(
e: PointerEvent | KeyboardEvent | undefined,
totalDelta: Coordinate,
): void;
/**
* Handles any drag cleanup when a drag is reverted.
*
* @param e Event that finished the drag.
* @param totalDelta The total distance, in pixels, that the draggable
* has moved since the start of the drag.
*/
onDragRevert(
e: PointerEvent | KeyboardEvent | undefined,
totalDelta: Coordinate,
): void;
}
@@ -0,0 +1,329 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import type {IDraggable} from '../interfaces/i_draggable.js';
import type {IDragger} from '../interfaces/i_dragger.js';
import * as registry from '../registry.js';
import {ShortcutRegistry} from '../shortcut_registry.js';
import {Coordinate} from '../utils/coordinate.js';
import {KeyCodes} from '../utils/keycodes.js';
import {MoveIndicator} from './move_indicator.js';
/**
* Cardinal directions in which a move can proceed.
*/
export enum Direction {
NONE,
UP,
DOWN,
LEFT,
RIGHT,
}
/**
* Identifier for a keyboard shortcut that commits the in-progress move.
*/
const COMMIT_MOVE_SHORTCUT = 'commitMove';
/**
* Class responsible for coordinating keyboard-driven moves with the workspace
* and dragging system.
*/
export class KeyboardMover {
/**
* Object responsible for dragging workspace elements in response to move
* commands.
*/
private dragger?: IDragger;
/**
* The object that is currently being moved.
*/
private draggable?: IDraggable;
/**
* Workspace coordinate that the current move started from.
*/
private startLocation?: Coordinate;
/**
* The total distance, in workspace coordinates, that the element being moved
* has been moved since the movement process started.
*/
private totalDelta = new Coordinate(0, 0);
/**
* The distance to move an item in workspace coordinates.
*/
private stepDistance = 20;
/**
* Symbol attached to the item being moved to indicate it is in move mode.
*/
private moveIndicator?: MoveIndicator;
private allowedShortcuts: string[] = [];
// Set up a blur listener to end the move if the user clicks away
private readonly blurListener = () => {
this.abortMove();
};
static mover = new KeyboardMover();
// Constructor is private to keep this class a singleton.
private constructor() {}
/**
* Returns true iff the given draggable is allowed to be moved.
*
* @param draggable The draggable element to try to move.
* @returns True iff movement is allowed.
*/
canMove(draggable: IDraggable) {
return !draggable.workspace.isReadOnly() && draggable.isMovable();
}
/**
* Returns true iff this Mover is currently moving an element.
*
* @returns True iff a workspace element is being moved.
*/
isMoving() {
return !!this.draggable;
}
/**
* Start moving the currently-focused item on workspace, if possible.
*
* @param draggable The element to start moving.
* @param event The keyboard event that triggered this move.
* @returns True iff a move has successfully begun.
*/
startMove(draggable: IDraggable, event: KeyboardEvent) {
if (!this.canMove(draggable) || this.isMoving()) return false;
const DraggerClass = registry.getClassFromOptions(
registry.Type.BLOCK_DRAGGER,
draggable.workspace.options,
true,
);
if (!DraggerClass) throw new Error('no Dragger registered');
this.dragger = new DraggerClass(draggable, draggable.workspace);
// Record that a move is in progress and start dragging.
this.draggable = this.dragger.onDragStart(event);
this.startLocation = this.draggable.getRelativeToSurfaceXY();
this.updateTotalDelta();
this.draggable
.getFocusableElement()
.addEventListener('blur', this.blurListener);
// Register a keyboard shortcut under the key combos of all existing
// keyboard shortcuts that commits the move before allowing the real
// shortcut to proceed. This avoids all kinds of fun brokenness when
// deleting/copying/otherwise acting on a element in move mode.
const shortcutKeys = Object.values(ShortcutRegistry.registry.getRegistry())
.flatMap((shortcut) => shortcut.keyCodes)
.filter((keyCode) => {
return (
keyCode &&
![
KeyCodes.RIGHT,
KeyCodes.LEFT,
KeyCodes.UP,
KeyCodes.DOWN,
KeyCodes.ENTER,
KeyCodes.ESC,
KeyCodes.M,
].includes(
typeof keyCode === 'number'
? keyCode
: parseInt(`${keyCode.split('+').pop()}`),
)
);
})
// Convince TS there aren't undefined values.
.filter((keyCode): keyCode is string | number => !!keyCode);
const commitMoveShortcut = {
name: COMMIT_MOVE_SHORTCUT,
preconditionFn: () => {
return this.isMoving();
},
callback: () => {
this.finishMove();
return false;
},
keyCodes: shortcutKeys,
allowCollision: true,
};
ShortcutRegistry.registry.register(commitMoveShortcut, true);
this.scrollCurrentElementIntoView();
this.moveIndicator = new MoveIndicator(this.draggable.workspace);
this.repositionMoveIndicator();
return true;
}
/**
* Moves the current element in the given direction.
*
* @param direction The direction to move the currently-moving element.
* @param event The event that triggered this move, if any.
* @returns True iff this action applies and has been performed.
*/
move(direction: Direction, event?: KeyboardEvent | PointerEvent) {
switch (direction) {
case Direction.UP:
this.totalDelta.y -= this.stepDistance;
break;
case Direction.DOWN:
this.totalDelta.y += this.stepDistance;
break;
case Direction.LEFT:
this.totalDelta.x -= this.stepDistance;
break;
case Direction.RIGHT:
this.totalDelta.x += this.stepDistance;
break;
}
this.dragger?.onDrag(event, this.totalPixelDelta());
this.updateTotalDelta();
this.scrollCurrentElementIntoView();
this.repositionMoveIndicator();
return true;
}
/**
* Finish moving the item that is currently being moved.
*
* @param event The event that triggered the end of the move, if any.
* @returns True iff move successfully finished.
*/
finishMove(event?: KeyboardEvent | PointerEvent) {
this.preDragEndCleanup();
this.dragger?.onDragEnd(event, this.totalPixelDelta());
this.postDragEndCleanup();
return true;
}
/**
* Abort moving the currently-focused item on workspace.
*
* @param event The event that triggered the end of the move, if any.
* @returns True iff move successfully aborted.
*/
abortMove(event?: KeyboardEvent | PointerEvent) {
this.preDragEndCleanup();
this.dragger?.onDragRevert(event, this.totalPixelDelta());
this.postDragEndCleanup();
return true;
}
/**
* Sets the distance by which an object will be moved.
*
* @param stepDistance The distance in workspace coordinates that each move
* should move elements on the workspace by.
*/
setMoveDistance(stepDistance: number) {
this.stepDistance = stepDistance;
}
/**
* Returns a list of the names of shortcuts that are allowed to be run while
* a keyboard-driven move is in progress.
*/
getAllowedShortcuts() {
return this.allowedShortcuts;
}
/**
* Adds shortcuts with the given names to the list of shortcuts that are
* allowed to be run while a keyboard-driven move is in progress.
*/
setAllowedShortcuts(shortcutNames: string[]) {
this.allowedShortcuts = shortcutNames;
}
/**
* Repositions the move indicator to the corner of the item being moved.
*/
private repositionMoveIndicator() {
const bounds = this.draggable?.getBoundingRectangle();
if (!bounds) return;
this.moveIndicator?.moveTo(
this.draggable?.workspace.RTL ? bounds.left : bounds.right,
bounds.top,
);
}
/**
* Common clean-up for finish/abort run before terminating the move.
*/
private preDragEndCleanup() {
ShortcutRegistry.registry.unregister(COMMIT_MOVE_SHORTCUT);
// Remove the blur listener before ending the drag
this.draggable
?.getFocusableElement()
.removeEventListener('blur', this.blurListener);
}
/**
* Common clean-up for finish/abort run after terminating the move.
*/
private postDragEndCleanup() {
this.moveIndicator?.dispose();
this.moveIndicator = undefined;
this.draggable = undefined;
this.dragger = undefined;
this.startLocation = undefined;
this.totalDelta = new Coordinate(0, 0);
}
/**
* Returns the total distance current element has moved in pixels.
*/
private totalPixelDelta() {
const scale = this.draggable?.workspace.scale ?? 1;
return new Coordinate(this.totalDelta.x * scale, this.totalDelta.y * scale);
}
/**
* Scrolls the current element into view.
*/
private scrollCurrentElementIntoView() {
if (!this.draggable) return;
const bounds = this.draggable.getBoundingRectangle();
this.draggable.workspace.scrollBoundsIntoView(bounds);
}
/**
* Recalculates the total movement delta from the starting location and the
* current position of the item being moved.
*/
private updateTotalDelta() {
if (!this.draggable || !this.startLocation) return;
this.totalDelta = new Coordinate(
this.draggable.getRelativeToSurfaceXY().x - this.startLocation.x,
this.draggable.getRelativeToSurfaceXY().y - this.startLocation.y,
);
}
}
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
/**
* Four-way arrow indicator attached to a workspace element to indicate that it
* is being moved.
*/
export class MoveIndicator {
/**
* Root SVG element for the indicator.
*/
svgRoot: SVGGElement;
/**
* Creates a new move indicator.
*
* @param workspace The workspace the indicator should be displayed on.
*/
constructor(private workspace: WorkspaceSvg) {
this.svgRoot = dom.createSvgElement(
Svg.G,
{},
workspace.getLayerManager()?.getDragLayer(),
);
this.svgRoot.classList.add('blocklyMoveIndicator');
const rtl = workspace.RTL;
dom.createSvgElement(
Svg.CIRCLE,
{
'fill': 'white',
'fill-opacity': '0.8',
'stroke': 'grey',
'stroke-width': '1',
'r': 20,
'cx': 20 * (rtl ? -1 : 1),
'cy': 20,
},
this.svgRoot,
);
dom.createSvgElement(
Svg.PATH,
{
'fill': 'none',
'stroke': 'black',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
'd': 'm18 9l3 3l-3 3m-3-3h6M6 9l-3 3l3 3m-3-3h6m0 6l3 3l3-3m-3-3v6m3-15l-3-3l-3 3m3-3v6',
'transform': `translate(${(rtl ? -4 : 1) * 8} 8)`,
},
this.svgRoot,
);
}
/**
* Moves this indicator to the specified location.
*
* @param x The location on the X axis to move to.
* @param y The location on the Y axis to move to.
*/
moveTo(x: number, y: number) {
this.svgRoot.setAttribute(
'transform',
`translate(${x + (this.workspace.RTL ? 20 : -20)}, ${y - 20})`,
);
}
/**
* Disposes of this move indicator.
*/
dispose() {
dom.removeNode(this.svgRoot);
}
}
+123 -2
View File
@@ -15,8 +15,10 @@ import {getFocusManager} from './focus_manager.js';
import {hasContextMenu} from './interfaces/i_contextmenu.js';
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
import {isDraggable} from './interfaces/i_draggable.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js';
import {type IDraggable, isDraggable} from './interfaces/i_draggable.js';
import {type IFocusableNode} from './interfaces/i_focusable_node.js';
import {Direction, KeyboardMover} from './keyboard_nav/keyboard_mover.js';
import {keyboardNavigationController} from './keyboard_navigation_controller.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {Coordinate} from './utils/coordinate.js';
import {KeyCodes} from './utils/keycodes.js';
@@ -374,6 +376,124 @@ export function registerRedo() {
ShortcutRegistry.registry.register(redoShortcut);
}
/**
* Registers keyboard shortcuts for keyboard-driven movement of workspace
* elements.
*/
export function registerMovementShortcuts() {
const getCurrentDraggable = (
workspace: WorkspaceSvg,
): IDraggable | undefined => {
const node = getFocusManager().getFocusedNode();
if (isDraggable(node)) return node;
return workspace.getCursor().getSourceBlock() ?? undefined;
};
const shortcuts: ShortcutRegistry.KeyboardShortcut[] = [
{
name: 'start_move',
preconditionFn: (workspace) => {
const startDraggable = getCurrentDraggable(workspace);
return !!startDraggable && KeyboardMover.mover.canMove(startDraggable);
},
callback: (workspace, e) => {
keyboardNavigationController.setIsActive(true);
const startDraggable = getCurrentDraggable(workspace);
// Focus the root draggable in case one of its children
// was focused when the move was triggered.
if (startDraggable) {
getFocusManager().focusNode(startDraggable);
}
return (
!!startDraggable &&
KeyboardMover.mover.startMove(startDraggable, e as KeyboardEvent)
);
},
keyCodes: [KeyCodes.M],
},
{
name: 'finish_move',
preconditionFn: () => KeyboardMover.mover.isMoving(),
callback: (_workspace, e) =>
KeyboardMover.mover.finishMove(e as KeyboardEvent),
keyCodes: [KeyCodes.ENTER, KeyCodes.SPACE],
allowCollision: true,
},
{
name: 'abort_move',
preconditionFn: () => KeyboardMover.mover.isMoving(),
callback: (_workspace, e) =>
KeyboardMover.mover.abortMove(e as KeyboardEvent),
keyCodes: [KeyCodes.ESC],
allowCollision: true,
},
{
name: 'move_left',
preconditionFn: () => KeyboardMover.mover.isMoving(),
callback: (_workspace, e) => {
e.preventDefault();
return KeyboardMover.mover.move(Direction.LEFT, e as KeyboardEvent);
},
keyCodes: [
KeyCodes.LEFT,
ShortcutRegistry.registry.createSerializedKey(KeyCodes.LEFT, [
KeyCodes.CTRL_CMD,
]),
],
allowCollision: true,
},
{
name: 'move_right',
preconditionFn: () => KeyboardMover.mover.isMoving(),
callback: (_workspace, e) => {
e.preventDefault();
return KeyboardMover.mover.move(Direction.RIGHT, e as KeyboardEvent);
},
keyCodes: [
KeyCodes.RIGHT,
ShortcutRegistry.registry.createSerializedKey(KeyCodes.RIGHT, [
KeyCodes.CTRL_CMD,
]),
],
allowCollision: true,
},
{
name: 'move_up',
preconditionFn: () => KeyboardMover.mover.isMoving(),
callback: (_workspace, e) => {
e.preventDefault();
return KeyboardMover.mover.move(Direction.UP, e as KeyboardEvent);
},
keyCodes: [
KeyCodes.UP,
ShortcutRegistry.registry.createSerializedKey(KeyCodes.UP, [
KeyCodes.CTRL_CMD,
]),
],
allowCollision: true,
},
{
name: 'move_down',
preconditionFn: () => KeyboardMover.mover.isMoving(),
callback: (_workspace, e) => {
e.preventDefault();
return KeyboardMover.mover.move(Direction.DOWN, e as KeyboardEvent);
},
keyCodes: [
KeyCodes.DOWN,
ShortcutRegistry.registry.createSerializedKey(KeyCodes.DOWN, [
KeyCodes.CTRL_CMD,
]),
],
allowCollision: true,
},
];
for (const shortcut of shortcuts) {
ShortcutRegistry.registry.register(shortcut);
}
}
/**
* Keyboard shortcut to show the context menu on ctrl/cmd+Enter.
*/
@@ -418,6 +538,7 @@ export function registerDefaultShortcuts() {
registerUndo();
registerRedo();
registerShowContextMenu();
registerMovementShortcuts();
}
registerDefaultShortcuts();
+12 -34
View File
@@ -59,6 +59,7 @@ import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import {hasBubble} from './interfaces/i_has_bubble.js';
import type {IMetricsManager} from './interfaces/i_metrics_manager.js';
import type {IToolbox} from './interfaces/i_toolbox.js';
import {KeyboardMover} from './keyboard_nav/keyboard_mover.js';
import type {LineCursor} from './keyboard_nav/line_cursor.js';
import type {Marker} from './keyboard_nav/marker.js';
import {LayerManager} from './layer_manager.js';
@@ -320,9 +321,6 @@ export class WorkspaceSvg
/** True if keyboard accessibility mode is on, false otherwise. */
keyboardAccessibilityMode = false;
/** True iff a keyboard-initiated move ("drag") is in progress. */
keyboardMoveInProgress = false; // TODO(#8960): Delete this.
/** The list of top-level bounded elements on the workspace. */
private topBoundedElements: IBoundedElement[] = [];
@@ -1427,13 +1425,17 @@ export class WorkspaceSvg
/**
* Returns the drag target the pointer event is over.
*
* @param e Pointer move event.
* @param e Pointer move event or a workspace coordinate.
* @returns Null if not over a drag target, or the drag target the event is
* over.
*/
getDragTarget(e: PointerEvent): IDragTarget | null {
getDragTarget(e: PointerEvent | Coordinate): IDragTarget | null {
const coordinate =
e instanceof Coordinate
? svgMath.wsToScreenCoordinates(this, e)
: new Coordinate(e.clientX, e.clientY);
for (let i = 0, targetArea; (targetArea = this.dragTargetAreas[i]); i++) {
if (targetArea.clientRect.contains(e.clientX, e.clientY)) {
if (targetArea.clientRect.contains(coordinate.x, coordinate.y)) {
return targetArea.component;
}
}
@@ -1472,27 +1474,6 @@ export class WorkspaceSvg
return drag.move(this, e);
}
/**
* Indicate whether a keyboard move is in progress or not.
*
* Should be called with true when a keyboard move of an IDraggable
* is starts, and false when it finishes or is aborted.
*
* N.B.: This method is experimental and internal-only. It is
* intended only to called only from the keyboard navigation plugin.
* Its signature and behaviour may be modified, or the method
* removed, at an time without notice and without being treated
* as a breaking change.
*
* TODO(#8960): Delete this.
*
* @internal
* @param inProgress Is a keyboard-initated move in progress?
*/
setKeyboardMoveInProgress(inProgress: boolean) {
this.keyboardMoveInProgress = inProgress;
}
/**
* Returns true iff the user is currently engaged in a drag gesture,
* or if a keyboard-initated move is in progress.
@@ -1509,9 +1490,7 @@ export class WorkspaceSvg
*/
isDragging(): boolean {
return (
// TODO(#8960): Query Mover.isMoving to see if move is in
// progress rather than relying on a status flag.
this.keyboardMoveInProgress ||
KeyboardMover.mover.isMoving() ||
(this.currentGesture_ !== null && this.currentGesture_.isDragging())
);
}
@@ -2468,10 +2447,9 @@ export class WorkspaceSvg
* @internal
*/
getGesture(e?: PointerEvent): Gesture | null {
// TODO(#8960): Query Mover.isMoving to see if move is in progress
// rather than relying on .keyboardMoveInProgress status flag.
if (this.keyboardMoveInProgress) {
// Normally these would be called from Gesture.doStart.
// Ignore and cancel events that would start a gesture during a
// keyboard-driven move.
if (KeyboardMover.mover.isMoving()) {
e?.preventDefault();
e?.stopPropagation();
return null;
+2 -1
View File
@@ -1,7 +1,7 @@
{
"@metadata": {
"author": "Ellen Spertus <ellen.spertus@gmail.com>",
"lastupdated": "2026-01-08 08:39:56.707280",
"lastupdated": "2026-02-12 13:23:33.999357",
"locale": "en",
"messagedocumentation" : "qqq"
},
@@ -409,6 +409,7 @@
"COMMAND_KEY": "⌘ Command",
"OPTION_KEY": "⌥ Option",
"ALT_KEY": "Alt",
"ENTER_KEY": "Enter",
"CUT_SHORTCUT": "Cut",
"COPY_SHORTCUT": "Copy",
"PASTE_SHORTCUT": "Paste",
+1
View File
@@ -416,6 +416,7 @@
"COMMAND_KEY": "Representation of the Mac Command key used in keyboard shortcuts.",
"OPTION_KEY": "Representation of the Mac Option key used in keyboard shortcuts.",
"ALT_KEY": "Representation of the Alt key used in keyboard shortcuts.",
"ENTER_KEY": "Representation of the Enter key used in keyboard shortcuts.",
"CUT_SHORTCUT": "menu label - Contextual menu item that cuts the focused item.",
"COPY_SHORTCUT": "menu label - Contextual menu item that copies the focused item.",
"PASTE_SHORTCUT": "menu label - Contextual menu item that pastes the previously copied item.",
+4 -1
View File
@@ -1626,7 +1626,7 @@ Blockly.Msg.EDIT_BLOCK_CONTENTS = 'Edit Block contents';
/// menu label - Contextual menu item that starts a keyboard-driven block move.
Blockly.Msg.MOVE_BLOCK = 'Move Block';
/** @type {string} */
/// Name of the Microsoft Windows operating system displayed in a list of
/// Name of the Microsoft Windows operating system displayed in a list of
/// keyboard shortcuts.
Blockly.Msg.WINDOWS = 'Windows';
/** @type {string} */
@@ -1658,6 +1658,9 @@ Blockly.Msg.OPTION_KEY = '⌥ Option';
/// Representation of the Alt key used in keyboard shortcuts.
Blockly.Msg.ALT_KEY = 'Alt';
/** @type {string} */
/// Representation of the Enter key used in keyboard shortcuts.
Blockly.Msg.ENTER_KEY = 'Enter';
/** @type {string} */
/// menu label - Contextual menu item that cuts the focused item.
Blockly.Msg.CUT_SHORTCUT = 'Cut';
/** @type {string} */
+1
View File
@@ -219,6 +219,7 @@
import './jso_deserialization_test.js';
import './jso_serialization_test.js';
import './json_test.js';
import './keyboard_movement_test.js';
import './keyboard_navigation_controller_test.js';
import './layering_test.js';
import './blocks/lists_test.js';
@@ -0,0 +1,861 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
import * as Blockly from '../../build/src/core/blockly.js';
import {assert} from '../../node_modules/chai/index.js';
import {
moveStatementTestBlocks,
moveValueTestBlocks,
} from './test_helpers/move_test_blocks.js';
import {p5blocks} from './test_helpers/p5_blocks.js';
import {
sharedTestSetup,
sharedTestTeardown,
} from './test_helpers/setup_teardown.js';
import {createKeyDownEvent} from './test_helpers/user_input.js';
suite('Keyboard-driven movement', function () {
setup(function () {
sharedTestSetup.call(this);
const toolbox = document.getElementById('toolbox-simple');
this.workspace = Blockly.inject('blocklyDiv', {toolbox: toolbox});
Blockly.common.defineBlocks(p5blocks);
Blockly.KeyboardMover.mover.setMoveDistance(20);
});
teardown(function () {
sharedTestTeardown.call(this);
});
function startMove(workspace) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.M);
workspace.getInjectionDiv().dispatchEvent(event);
}
function moveUp(workspace, modifiers) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.UP, modifiers);
workspace.getInjectionDiv().dispatchEvent(event);
}
function moveDown(workspace, modifiers) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.DOWN, modifiers);
workspace.getInjectionDiv().dispatchEvent(event);
}
function moveLeft(workspace, modifiers) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.LEFT, modifiers);
workspace.getInjectionDiv().dispatchEvent(event);
}
function moveRight(workspace, modifiers) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.RIGHT, modifiers);
workspace.getInjectionDiv().dispatchEvent(event);
}
function cancelMove(workspace) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ESC);
workspace.getInjectionDiv().dispatchEvent(event);
}
function endMove(workspace) {
const event = createKeyDownEvent(Blockly.utils.KeyCodes.ENTER);
workspace.getInjectionDiv().dispatchEvent(event);
}
/**
* Create a new block from serialised state (parsed JSON) and
* optionally attach it to an existing block on the workspace.
*
* @param {!Blockly.WorkspaceSvg} workspace The workspace to create the block
* on.
* @param {!Blockly.serialization.blocks.State} state The JSON definition of
* the new block.
* @param {?string} parentId The ID of the block to attach to. If undefined,
* the new block is not attached.
* @param {?string} inputName The name of the input on the parent block to
* attach to. If undefined, the new block is attached to the
* parent's next connection.
* @returns {!Promise<string>} A promise that resolves with the new block's
* ID.
*/
function appendBlock(workspace, state, parentId, inputName) {
const block = Blockly.serialization.blocks.append(state, workspace);
if (!block) throw new Error('failed to create block from state');
if (!parentId) return block.id;
try {
const parent = workspace.getBlockById(parentId);
if (!parent) throw new Error(`parent block not found: ${parentId}`);
let parentConnection;
let childConnection;
if (inputName) {
parentConnection = parent.getInput(inputName)?.connection;
if (!parentConnection) {
throw new Error(`input ${inputName} not found on parent`);
}
childConnection = block.outputConnection ?? block.previousConnection;
} else {
parentConnection = parent.nextConnection;
if (!parentConnection) {
throw new Error('parent has no next connection');
}
childConnection = block.previousConnection;
}
if (!childConnection) throw new Error('new block not compatible');
parentConnection.connect(childConnection);
return block.id;
} catch (e) {
// If anything goes wrong during attachment, clean up the new block.
block.dispose();
throw e;
}
}
/**
* Get information about the currently-focused block's parent and
* child blocks.
*
* @returns {!Promise<{parentId: string | null, parentIndex: number | null, nextId: string | null, valueId: string | null}>} A promise resolving to
*
* {parentId, parentIndex, nextId, valueId}
*
* where parentId, parentIndex are the ID of the parent block and
* the index of the connection on that block to which the
* currently-focused block is connected, nextId is the ID of block
* connected to the focused block's next connection, and valueID
* is the ID of a block connected to the zeroth input of the
* focused block (or, in each case, null if there is no such
* block).
*/
async function getFocusedNeighbourInfo() {
return Blockly.renderManagement.finishQueuedRenders().then(() => {
const block = Blockly.getFocusManager().getFocusedNode();
if (!block) throw new Error('nothing focused');
if (!(block instanceof Blockly.BlockSvg)) {
throw new TypeError('focused node is not a BlockSvg');
}
const parent = block?.getParent();
return {
parentId: parent?.id ?? null,
parentIndex:
parent
?.getConnections_(true)
.findIndex((conn) => conn.targetBlock() === block) ?? null,
nextId: block?.getNextBlock()?.id ?? null,
valueId: block?.inputList[0].connection?.targetBlock()?.id ?? null,
};
});
}
/**
* Get information about the connection candidate for the
* currently-moving block (if any).
*
* @returns {!Promise<{id: string, index: number, ownIndex: number} | null>} A
* promise resolving to either null if there is no connection
* candidate, or otherwise if there is one to
*
* {id, index, ownIndex}
*
* where id is the block ID of the neighbour, index is the index
* of the candidate connection on the neighbour, and ownIndex is
* the index of the candidate connection on the moving block.
*/
function getConnectionCandidate() {
const focused = Blockly.getFocusManager().getFocusedNode();
if (!focused) throw new Error('nothing focused');
if (!(focused instanceof Blockly.BlockSvg)) {
throw new TypeError('focused node is not a BlockSvg');
}
const block = focused; // Inferred as BlockSvg.
const dragStrategy = block.getDragStrategy();
if (!dragStrategy) throw new Error('no drag strategy');
const candidate = dragStrategy.connectionCandidate;
if (!candidate) return null;
const neighbourBlock = candidate.neighbour.getSourceBlock();
if (!neighbourBlock) throw new TypeError('connection has no source block');
const neighbourConnections = neighbourBlock.getConnections_(true);
const index = neighbourConnections.indexOf(candidate.neighbour);
const ownConnections = block.getConnections_(true);
const ownIndex = ownConnections.indexOf(candidate.local);
return {id: neighbourBlock.id, index, ownIndex};
}
/**
* Create a mocha test function moving a specified block in a
* particular direction, checking that it has the the expected
* connection candidate after each step, and that once the move
* finishes that the moving block is reconnected to its initial
* location.
*
* @param {!string} mover Block ID of the block to be moved.
* @param {!Blockly.utils.KeyCodes} key Key to send to move one step.
* @param {!Blockly.RenderedConnection[]} candidates Array of expected
* connection candidates.
* @returns {!function} function to pass as second argument to mocha's test
* function.
*/
function moveTest(mover, key, candidates) {
return async function () {
// Navigate to block to be moved and initiate move.
const block = this.workspace.getBlockById(mover);
Blockly.getFocusManager().focusNode(block);
const initialInfo = await getFocusedNeighbourInfo();
startMove(this.workspace);
// Press specified key multiple times, checking connection candidates.
for (let i = 0; i < candidates.length; i++) {
const candidate = getConnectionCandidate();
assert.deepEqual(candidate, candidates[i]);
const event = createKeyDownEvent(key);
this.workspace.getInjectionDiv().dispatchEvent(event);
}
// Finish move and check final location of moved block.
endMove(this.workspace);
const finalInfo = await getFocusedNeighbourInfo();
assert.deepEqual(initialInfo, finalInfo);
};
}
function testMovingUp() {
test('can move them up', function () {
Blockly.getFocusManager().focusNode(this.element);
const originalBounds = this.element.getBoundingRectangle();
startMove(this.workspace);
moveUp(this.workspace, this.modifiers);
endMove(this.workspace);
const newBounds = this.element.getBoundingRectangle();
assert.isBelow(newBounds.top, originalBounds.top);
assert.equal(newBounds.left, originalBounds.left);
});
}
function testMovingDown() {
test('can move them down', function () {
Blockly.getFocusManager().focusNode(this.element);
const originalBounds = this.element.getBoundingRectangle();
startMove(this.workspace);
moveDown(this.workspace, this.modifiers);
endMove(this.workspace);
const newBounds = this.element.getBoundingRectangle();
assert.isAbove(newBounds.bottom, originalBounds.bottom);
assert.equal(newBounds.left, originalBounds.left);
});
}
function testMovingLeft() {
test('can move them left', function () {
Blockly.getFocusManager().focusNode(this.element);
const originalBounds = this.element.getBoundingRectangle();
startMove(this.workspace);
moveLeft(this.workspace, this.modifiers);
endMove(this.workspace);
const newBounds = this.element.getBoundingRectangle();
assert.isBelow(newBounds.left, originalBounds.left);
assert.equal(newBounds.top, originalBounds.top);
});
}
function testMovingRight() {
test('can move them right', function () {
Blockly.getFocusManager().focusNode(this.element);
const originalBounds = this.element.getBoundingRectangle();
startMove(this.workspace);
moveRight(this.workspace, this.modifiers);
endMove(this.workspace);
const newBounds = this.element.getBoundingRectangle();
assert.isAbove(newBounds.right, originalBounds.right);
assert.equal(newBounds.top, originalBounds.top);
});
}
function testCancelingMove() {
test('can be cancelled', function () {
Blockly.getFocusManager().focusNode(this.element);
const originalBounds = this.element.getBoundingRectangle();
startMove(this.workspace);
moveRight(this.workspace, this.modifiers);
moveUp(this.workspace, this.modifiers);
cancelMove(this.workspace);
const newBounds = this.element.getBoundingRectangle();
assert.deepEqual(newBounds, originalBounds);
});
}
function testMoveIndicatorIsDisplayed() {
test('displays an attached move indicator while moving', function () {
Blockly.getFocusManager().focusNode(this.element);
assert.equal(
this.workspace
.getInjectionDiv()
.querySelectorAll('.blocklyMoveIndicator').length,
0,
);
startMove(this.workspace);
moveRight(this.workspace, this.modifiers);
assert.equal(
this.workspace
.getInjectionDiv()
.querySelectorAll('.blocklyMoveIndicator').length,
1,
);
endMove(this.workspace);
assert.equal(
this.workspace
.getInjectionDiv()
.querySelectorAll('.blocklyMoveIndicator').length,
0,
);
});
}
function testAdjustingMoveStepSize() {
test('respects configured step size', function () {
Blockly.getFocusManager().focusNode(this.element);
startMove(this.workspace);
const steps = [100, 20, 0, -20, -100];
for (const step of steps) {
Blockly.KeyboardMover.mover.setMoveDistance(step);
const oldLeft = this.element.getBoundingRectangle().left;
moveRight(this.workspace, this.modifiers);
const newLeft = this.element.getBoundingRectangle().left;
assert.equal(newLeft - oldLeft, step);
}
endMove(this.workspace);
});
}
function testUnrelatedShortcutCommits() {
test('is committed when unrelated shortcuts are performed', function () {
const oldBounds = this.element.getBoundingRectangle();
Blockly.getFocusManager().focusNode(this.element);
startMove(this.workspace);
assert.isTrue(Blockly.KeyboardMover.mover.isMoving());
moveRight(this.workspace, this.modifiers);
moveRight(this.workspace, this.modifiers);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.C, [
Blockly.utils.KeyCodes.CTRL_CMD,
]);
this.workspace.getInjectionDiv().dispatchEvent(event);
assert.isFalse(Blockly.KeyboardMover.mover.isMoving());
const newBounds = this.element.getBoundingRectangle();
oldBounds.left += 40;
oldBounds.right += 40;
assert.deepEqual(newBounds, oldBounds);
});
}
function testExemptedShortcutsAllowed() {
test('is not committed when allowlisted shortcuts are performed', function () {
const hotkey = Blockly.ShortcutRegistry.registry.createSerializedKey(
Blockly.utils.KeyCodes.M,
[Blockly.utils.KeyCodes.CTRL_CMD],
);
let shortcutRun = false;
const testShortcut = {
name: 'test_shortcut',
preconditionFn: () => true,
callback: () => {
shortcutRun = true;
return true;
},
keyCodes: [hotkey],
};
Blockly.ShortcutRegistry.registry.register(testShortcut);
Blockly.getFocusManager().focusNode(this.element);
startMove(this.workspace);
assert.isTrue(Blockly.KeyboardMover.mover.isMoving());
moveRight(this.workspace, this.modifiers);
const event = createKeyDownEvent(Blockly.utils.KeyCodes.M, [
Blockly.utils.KeyCodes.CTRL_CMD,
]);
this.workspace.getInjectionDiv().dispatchEvent(event);
// Move mode should still be active and the shortcut should have toggled
// the `shortcutRun` variable.
assert.isTrue(Blockly.KeyboardMover.mover.isMoving());
assert.isTrue(shortcutRun);
cancelMove(this.workspace);
Blockly.ShortcutRegistry.registry.unregister('test_shortcut');
});
}
suite('of workspace comments', function () {
setup(function () {
this.element = new Blockly.comments.RenderedWorkspaceComment(
this.workspace,
);
});
testMovingUp();
testMovingDown();
testMovingLeft();
testMovingRight();
testCancelingMove();
testMoveIndicatorIsDisplayed();
testAdjustingMoveStepSize();
testUnrelatedShortcutCommits();
testExemptedShortcutsAllowed();
});
suite('of blocks', function () {
setup(function () {
this.element = this.workspace.newBlock('logic_boolean');
this.element.initSvg();
this.element.render();
this.modifiers = [Blockly.utils.KeyCodes.CTRL_CMD];
});
suite('in unconstrained mode', function () {
testMovingUp();
testMovingDown();
testMovingLeft();
testMovingRight();
testCancelingMove();
testMoveIndicatorIsDisplayed();
testAdjustingMoveStepSize();
testUnrelatedShortcutCommits();
testExemptedShortcutsAllowed();
});
suite('in constrained mode', function () {
test('prompts to use unconstrained mode when no destinations are available', function () {
const toastSpy = sinon.spy(Blockly.Toast, 'show');
Blockly.getFocusManager().focusNode(this.element);
const originalBounds = this.element.getBoundingRectangle();
startMove(this.workspace);
moveUp(this.workspace);
endMove(this.workspace);
const newBounds = this.element.getBoundingRectangle();
assert.deepEqual(newBounds, originalBounds);
assert.equal(
toastSpy.args[0][1]['message'],
Blockly.utils.userAgent.MAC
? 'Hold ⌘ Command and use arrow keys to move freely, then Enter to accept the position'
: 'Hold Ctrl and use arrow keys to move freely, then Enter to accept the position',
);
toastSpy.restore();
});
suite('Statement move tests', function () {
// Clear the workspace and load start blocks.
setup(function () {
Blockly.serialization.workspaces.load(
moveStatementTestBlocks,
this.workspace,
);
});
/** Serialized simple statement block with no statement inputs. */
const STATEMENT_SIMPLE = {
type: 'draw_emoji',
id: 'simple_mover',
fields: {emoji: '✨'},
};
/**
* Expected connection candidates when moving a block with no
* inputs, after pressing right (or down) arrow n times.
*/
const EXPECTED_SIMPLE_RIGHT = [
{id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location.
{id: 'text_print', index: 0, ownIndex: 1}, // Previous.
{id: 'text_print', index: 1, ownIndex: 0}, // Next.
{id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input.
{id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input.
{id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next.
{id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input.
{id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input.
{id: 'controls_if', index: 1, ownIndex: 0}, // Next.
{id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input.
];
/**
* Expected connection candidates when moving STATEMENT_SIMPLE after
* pressing left (or up) arrow n times.
*/
const EXPECTED_SIMPLE_LEFT = EXPECTED_SIMPLE_RIGHT.slice(0, 1).concat(
EXPECTED_SIMPLE_RIGHT.slice(1).reverse(),
);
suite('Constrained moves of simple statement block', function () {
setup(function () {
appendBlock(this.workspace, STATEMENT_SIMPLE, 'p5_canvas');
});
test(
'moving right',
moveTest(
STATEMENT_SIMPLE.id,
Blockly.utils.KeyCodes.RIGHT,
EXPECTED_SIMPLE_RIGHT,
),
);
test(
'moving left',
moveTest(
STATEMENT_SIMPLE.id,
Blockly.utils.KeyCodes.LEFT,
EXPECTED_SIMPLE_LEFT,
),
);
test(
'moving down',
moveTest(
STATEMENT_SIMPLE.id,
Blockly.utils.KeyCodes.DOWN,
EXPECTED_SIMPLE_RIGHT,
),
);
test(
'moving up',
moveTest(
STATEMENT_SIMPLE.id,
Blockly.utils.KeyCodes.UP,
EXPECTED_SIMPLE_LEFT,
),
);
});
/** Serialized statement block with multiple statement inputs. */
const STATEMENT_COMPLEX = {
type: 'controls_if',
id: 'complex_mover',
extraState: {hasElse: true},
};
/**
* Expected connection candidates when moving STATEMENT_COMPLEX, after
* pressing right (or down) arrow n times.
*/
const EXPECTED_COMPLEX_RIGHT = [
// TODO(#702): With the current behavior, certain connection
// candidates that can be found using the mouse are not visited when
// doing a keyboard move. They appear in the list below, but commented
// out for now. They should be uncommented if the behavior is changed.
{id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location again.
// {id: 'text_print', index: 0, ownIndex: 1}, // Previous to own next.
{id: 'text_print', index: 0, ownIndex: 4}, // Previous to own else input.
// {id: 'text_print', index: 0, ownIndex: 3}, // Previous to own if input.
{id: 'text_print', index: 1, ownIndex: 0}, // Next.
{id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input.
{id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input.
{id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next.
{id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input.
{id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input.
{id: 'controls_if', index: 1, ownIndex: 0}, // Next.
{id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input.
];
/**
* Expected connection candidates when moving STATEMENT_COMPLEX after
* pressing left or up arrow n times.
*/
const EXPECTED_COMPLEX_LEFT = EXPECTED_COMPLEX_RIGHT.slice(0, 1).concat(
EXPECTED_COMPLEX_RIGHT.slice(1).reverse(),
);
suite(
'Constrained moves of stack block with statement inputs',
function () {
setup(function () {
appendBlock(this.workspace, STATEMENT_COMPLEX, 'p5_canvas');
});
test(
'moving right',
moveTest(
STATEMENT_COMPLEX.id,
Blockly.utils.KeyCodes.RIGHT,
EXPECTED_COMPLEX_RIGHT,
),
);
test(
'moving left',
moveTest(
STATEMENT_COMPLEX.id,
Blockly.utils.KeyCodes.LEFT,
EXPECTED_COMPLEX_LEFT,
),
);
test(
'moving down',
moveTest(
STATEMENT_COMPLEX.id,
Blockly.utils.KeyCodes.DOWN,
EXPECTED_COMPLEX_RIGHT,
),
);
test(
'moving up',
moveTest(
STATEMENT_COMPLEX.id,
Blockly.utils.KeyCodes.UP,
EXPECTED_COMPLEX_LEFT,
),
);
},
);
// When a top-level block with no previous, next or output
// connections is subject to a constrained move, it should not move.
//
// This includes a regression test for issue #446 (fixed in PR #599)
// where, due to an implementation error in Mover, constrained
// movement following unconstrained movement would result in the
// block unexpectedly moving (unless workspace scale was === 1).
test('Constrained move of unattachable top-level block', async function () {
// Block ID of an unconnectable block.
const BLOCK = Blockly.getMainWorkspace().getBlockById('p5_setup');
// Scale workspace.
this.workspace.setScale(0.9);
// Navigate to unconnectable block, get initial coords and start move.
Blockly.getFocusManager().focusNode(BLOCK);
const startCoordinate = BLOCK.getBoundingRectangle();
startMove(this.workspace);
// Check constrained moves have no effect.
for (let i = 0; i < 5; i++) {
moveDown(this.workspace);
}
const coordinate = BLOCK.getBoundingRectangle();
assert.deepEqual(
coordinate,
startCoordinate,
'constrained move should have no effect',
);
cancelMove(this.workspace);
});
});
suite(`Value expression move tests`, function () {
/** Serialized simple reporter value block with no inputs. */
const VALUE_SIMPLE = {
type: 'text',
id: 'simple_mover',
fields: {TEXT: 'simple mover'},
};
/**
* Expected connection candidates when moving VALUE_SIMPLE, after
* pressing ArrowRight n times.
*/
const EXPECTED_SIMPLE_RIGHT = [
{id: 'join0', index: 1, ownIndex: 0}, // Join block ADD0 input.
{id: 'join0', index: 2, ownIndex: 0}, // Join block ADD1 input.
{id: 'print1', index: 2, ownIndex: 0}, // Print block with no shadow.
{id: 'print2', index: 2, ownIndex: 0}, // Print block with shadow.
// Skip draw_emoji block as it has no value inputs.
{id: 'print3', index: 2, ownIndex: 0}, // Replacing join expression.
{id: 'join1', index: 1, ownIndex: 0}, // Join block ADD0 input.
{id: 'join1', index: 2, ownIndex: 0}, // Join block ADD1 input.
// Skip controls_repeat_ext block's TIMES input as it is incompatible.
{id: 'print4', index: 2, ownIndex: 0}, // Replacing join expression.
{id: 'join2', index: 1, ownIndex: 0}, // Join block ADD0 input.
{id: 'join2', index: 2, ownIndex: 0}, // Join block ADD1 input.
// Skip input of unattached join block.
];
/**
* Expected connection candidates when moving BLOCK_SIMPLE, after
* pressing ArrowLeft n times.
*/
const EXPECTED_SIMPLE_LEFT = EXPECTED_SIMPLE_RIGHT.slice(0, 1).concat(
EXPECTED_SIMPLE_RIGHT.slice(1).reverse(),
);
/**
* Serialized row of value blocks with no free inputs; should behave
* as VALUE_SIMPLE does.
*/
const VALUE_ROW = {
type: 'text_changeCase',
id: 'row_mover',
fields: {CASE: 'TITLECASE'},
inputs: {
TEXT: {block: VALUE_SIMPLE},
},
};
// EXPECTED_ROW_RIGHT will be same as EXPECTED_SIMPLE_RIGHT (and
// same for ..._LEFT).
/** Serialized value block with a single free (external) input. */
const VALUE_UNARY = {
type: 'text_changeCase',
id: 'unary_mover',
fields: {CASE: 'TITLECASE'},
};
/**
* Expected connection candidates when moving VALUE_UNARY after
* pressing ArrowRight n times.
*/
const EXPECTED_UNARY_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([
{id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input.
]);
/**
* Expected connection candidates when moving row consisting of
* BLOCK_UNARY on its own after pressing ArrowLEFT n times.
*/
const EXPECTED_UNARY_LEFT = EXPECTED_UNARY_RIGHT.slice(0, 1).concat(
EXPECTED_UNARY_RIGHT.slice(1).reverse(),
);
/** Serialized value block with a single free (external) input. */
const VALUE_COMPLEX = {
type: 'text_join',
id: 'complex_mover',
};
/**
* Expected connection candidates when moving VALUE_COMPLEX after
* pressing ArrowRight n times.
*/
const EXPECTED_COMPLEX_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([
// TODO(#702): With the current behavior, certain connection
// candidates that can be found using the mouse are not visited when
// doing a keyboard move. They appear in the list below, but commented
// out for now. They should be uncommented if the behavior is changed.
{id: 'join0', index: 0, ownIndex: 2}, // Unattached block to own input.
// {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input.
]);
/**
* Expected connection candidates when moving row consisting of
* BLOCK_COMPLEX on its own after pressing ArrowLEFT n times.
*/
const EXPECTED_COMPLEX_LEFT = EXPECTED_COMPLEX_RIGHT.slice(0, 1).concat(
EXPECTED_COMPLEX_RIGHT.slice(1).reverse(),
);
for (const renderer of ['geras', 'thrasos', 'zelos']) {
suite(`using ${renderer}`, function () {
// Clear the workspace and load start blocks.
setup(function () {
const toolbox = document.getElementById('toolbox-simple');
this.workspace = Blockly.inject('blocklyDiv', {
toolbox: toolbox,
renderer: renderer,
});
Blockly.serialization.workspaces.load(
moveValueTestBlocks,
this.workspace,
);
});
suite('Constrained moves of a simple reporter block', function () {
setup(function () {
appendBlock(this.workspace, VALUE_SIMPLE, 'join0', 'ADD0');
});
test(
'moving right',
moveTest(
VALUE_SIMPLE.id,
Blockly.utils.KeyCodes.RIGHT,
EXPECTED_SIMPLE_RIGHT,
),
);
test(
'moving left',
moveTest(
VALUE_SIMPLE.id,
Blockly.utils.KeyCodes.LEFT,
EXPECTED_SIMPLE_LEFT,
),
);
});
suite('Constrained moves of row of value blocks', function () {
setup(function () {
appendBlock(this.workspace, VALUE_ROW, 'join0', 'ADD0');
});
test(
'moving right',
moveTest(
VALUE_ROW.id,
Blockly.utils.KeyCodes.RIGHT,
EXPECTED_SIMPLE_RIGHT,
),
);
test(
'moving left',
moveTest(
VALUE_ROW.id,
Blockly.utils.KeyCodes.LEFT,
EXPECTED_SIMPLE_LEFT,
),
);
});
suite('Constrained moves of unary expression block', function () {
setup(function () {
appendBlock(this.workspace, VALUE_UNARY, 'join0', 'ADD0');
});
test(
'moving right',
moveTest(
VALUE_UNARY.id,
Blockly.utils.KeyCodes.RIGHT,
EXPECTED_UNARY_RIGHT,
),
);
test(
'moving left',
moveTest(
VALUE_UNARY.id,
Blockly.utils.KeyCodes.LEFT,
EXPECTED_UNARY_LEFT,
),
);
});
suite(
'Constrained moves of a complex expression block',
function () {
setup(function () {
appendBlock(this.workspace, VALUE_COMPLEX, 'join0', 'ADD0');
});
test(
'moving right',
moveTest(
VALUE_COMPLEX.id,
Blockly.utils.KeyCodes.RIGHT,
EXPECTED_COMPLEX_RIGHT,
),
);
test(
'moving left',
moveTest(
VALUE_COMPLEX.id,
Blockly.utils.KeyCodes.LEFT,
EXPECTED_COMPLEX_LEFT,
),
);
},
);
});
}
});
});
});
suite('of bubbles', function () {
setup(async function () {
const commentBlock = this.workspace.newBlock('logic_boolean');
commentBlock.setCommentText('Hello world');
const icon = commentBlock.getIcon(Blockly.icons.IconType.COMMENT);
await icon.setBubbleVisible(true);
this.element = icon.getBubble();
});
testMovingUp();
testMovingDown();
testMovingLeft();
testMovingRight();
testCancelingMove();
testMoveIndicatorIsDisplayed();
testAdjustingMoveStepSize();
testUnrelatedShortcutCommits();
testExemptedShortcutsAllowed();
});
});
@@ -0,0 +1,528 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/
// The draw block contains a stack of statement blocks, each of which
// has a value input to which is connected a value expression block
// which itself has one or two inputs which have (non-shadow) simple
// value blocks connected. Each statement block will be selected in
// turn and then a move initiated (and then aborted). This is then
// repeated with the first level value blocks (those that are attached
// to the statement blocks). The second level value blocks are
// present to verify correct (lack of) heal behaviour.
const moveStartTestBlocks = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'p5_setup',
'id': 'p5_setup_1',
'x': 0,
'y': 75,
'deletable': false,
'inputs': {
'STATEMENTS': {
'block': {
'type': 'p5_canvas',
'id': 'p5_canvas_1',
'deletable': false,
'movable': false,
'fields': {
'WIDTH': 400,
'HEIGHT': 400,
},
},
},
},
},
{
'type': 'p5_draw',
'id': 'p5_draw_1',
'x': 0,
'y': 332,
'deletable': false,
'inputs': {
'STATEMENTS': {
'block': {
'type': 'controls_if',
'id': 'statement_1',
'inputs': {
'IF0': {
'block': {
'type': 'logic_operation',
'id': 'value_1',
'fields': {
'OP': 'AND',
},
'inputs': {
'A': {
'block': {
'type': 'logic_boolean',
'id': 'value_1_1',
'fields': {
'BOOL': 'TRUE',
},
},
},
'B': {
'block': {
'type': 'logic_boolean',
'id': 'value_1_2',
'fields': {
'BOOL': 'TRUE',
},
},
},
},
},
},
},
'next': {
'block': {
'type': 'controls_if',
'id': 'statement_2',
'inputs': {
'IF0': {
'block': {
'type': 'logic_negate',
'id': 'value_2',
'inputs': {
'BOOL': {
'block': {
'type': 'logic_boolean',
'id': 'value_2_1',
'fields': {
'BOOL': 'TRUE',
},
},
},
},
},
},
},
'next': {
'block': {
'type': 'controls_repeat_ext',
'id': 'statement_3',
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'id': 'shadow_3',
'fields': {
'NUM': 10,
},
},
'block': {
'type': 'math_arithmetic',
'id': 'value_3',
'fields': {
'OP': 'ADD',
},
'inputs': {
'A': {
'shadow': {
'type': 'math_number',
'id': 'shadow_3_1',
'fields': {
'NUM': 1,
},
},
'block': {
'type': 'math_number',
'id': 'value_3_1',
'fields': {
'NUM': 0,
},
},
},
'B': {
'shadow': {
'type': 'math_number',
'id': 'shadow_3_2',
'fields': {
'NUM': 1,
},
},
'block': {
'type': 'math_number',
'id': 'value_3_2',
'fields': {
'NUM': 0,
},
},
},
},
},
},
},
'next': {
'block': {
'type': 'controls_repeat_ext',
'id': 'statement_4',
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'id': 'shadow_4',
'fields': {
'NUM': 10,
},
},
'block': {
'type': 'math_trig',
'id': 'value_4',
'fields': {
'OP': 'SIN',
},
'inputs': {
'NUM': {
'shadow': {
'type': 'math_number',
'id': 'shadow_4_1',
'fields': {
'NUM': 45,
},
},
'block': {
'type': 'math_number',
'id': 'value_4_1',
'fields': {
'NUM': 180,
},
},
},
},
},
},
},
'next': {
'block': {
'type': 'text_print',
'id': 'statement_5',
'inputs': {
'TEXT': {
'shadow': {
'type': 'text',
'id': 'shadow_5',
'fields': {
'TEXT': 'abc',
},
},
'block': {
'type': 'text_join',
'id': 'value_5',
'extraState': {
'itemCount': 2,
},
'inputs': {
'ADD0': {
'block': {
'type': 'text',
'id': 'value_5_1',
'fields': {
'TEXT': 'test',
},
},
},
'ADD1': {
'block': {
'type': 'text',
'id': 'value_5_2',
'fields': {
'TEXT': 'test',
},
},
},
},
},
},
},
'next': {
'block': {
'type': 'text_print',
'id': 'statement_6',
'inputs': {
'TEXT': {
'shadow': {
'type': 'text',
'id': 'shadow_6',
'fields': {
'TEXT': 'abc',
},
},
'block': {
'type': 'text_reverse',
'id': 'value_6',
'inputs': {
'TEXT': {
'shadow': {
'type': 'text',
'id': 'shadow_6_1',
'fields': {
'TEXT': '',
},
},
'block': {
'type': 'text',
'id': 'value_6_1',
'fields': {
'TEXT': 'test',
},
},
},
},
},
},
},
'next': {
'block': {
'type': 'draw_emoji',
'id': 'statement_7',
'fields': {
'emoji': '❤️',
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
],
},
};
// A bunch of statement blocks. It is intended that statement blocks
// to be moved can be attached to the next connection of p5_canvas,
// and then be (constrained-)moved up, down, left and right to verify
// that they visit all the expected candidate connections.
const moveStatementTestBlocks = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'p5_setup',
'id': 'p5_setup',
'x': 75,
'y': 75,
'deletable': false,
'inputs': {
'STATEMENTS': {
'block': {
'type': 'p5_canvas',
'id': 'p5_canvas',
'deletable': false,
'movable': false,
'fields': {
'WIDTH': 400,
'HEIGHT': 400,
},
},
},
},
},
{
'type': 'text_print',
'id': 'text_print',
'disabledReasons': ['MANUALLY_DISABLED'],
'x': 75,
'y': 400,
'inputs': {
'TEXT': {
'shadow': {
'type': 'text',
'id': 'shadow_text',
'fields': {
'TEXT': 'abc',
},
},
},
},
'next': {
'block': {
'type': 'controls_if',
'id': 'controls_if',
'extraState': {
'elseIfCount': 1,
'hasElse': true,
},
'inputs': {
'DO0': {
'block': {
'type': 'controls_repeat_ext',
'id': 'controls_repeat_ext',
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'id': 'shadow_math_number',
'fields': {
'NUM': 10,
},
},
},
},
},
},
},
},
},
},
{
'type': 'p5_draw',
'id': 'p5_draw',
'x': 75,
'y': 950,
'deletable': false,
},
],
},
};
const moveValueTestBlocks = {
'blocks': {
'languageVersion': 0,
'blocks': [
{
'type': 'p5_setup',
'id': 'p5_setup',
'x': 75,
'y': 75,
'deletable': false,
'inputs': {
'STATEMENTS': {
'block': {
'type': 'p5_canvas',
'id': 'p5_canvas',
'deletable': false,
'movable': false,
'fields': {
'WIDTH': 400,
'HEIGHT': 400,
},
},
},
},
},
{
'type': 'text_join',
'id': 'join0',
'x': 75,
'y': 200,
},
{
'type': 'p5_draw',
'id': 'p5_draw',
'x': 75,
'y': 300,
'deletable': false,
'inputs': {
'STATEMENTS': {
'block': {
'type': 'text_print',
'id': 'print1',
'next': {
'block': {
'type': 'text_print',
'id': 'print2',
'inputs': {
'TEXT': {
'shadow': {
'type': 'text',
'id': 'shadow_print2',
'fields': {
'TEXT': 'shadow',
},
},
},
},
'next': {
'block': {
'type': 'draw_emoji',
'id': 'draw_emoji',
'fields': {
'emoji': '🐻',
},
'next': {
'block': {
'type': 'text_print',
'id': 'print3',
'inputs': {
'TEXT': {
'block': {
'type': 'text_join',
'id': 'join1',
'inline': true,
'inputs': {
'ADD0': {
'shadow': {
'type': 'text',
'id': 'shadow_join',
'fields': {
'TEXT': 'inline',
},
},
},
},
},
},
},
'next': {
'block': {
'type': 'controls_repeat_ext',
'id': 'controls_repeat_ext',
'inputs': {
'TIMES': {
'shadow': {
'type': 'math_number',
'id': 'shadow_repeat',
'fields': {
'NUM': 1,
},
},
},
'DO': {
'block': {
'type': 'text_print',
'id': 'print4',
'inputs': {
'TEXT': {
'block': {
'type': 'text_join',
'id': 'join2',
'inline': false,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
],
},
};
export {moveStartTestBlocks, moveStatementTestBlocks, moveValueTestBlocks};
@@ -0,0 +1,358 @@
/* eslint-disable camelcase */
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as Blockly from '../../../build/src/core/blockly.js';
// p5 Basic Setup Blocks
const p5SetupJson = {
'type': 'p5_setup',
'message0': 'setup %1',
'args0': [
{
'type': 'input_statement',
'name': 'STATEMENTS',
},
],
'colour': 300,
'tooltip': 'Setup the p5 canvas. This code is run once.',
'helpUrl': '',
};
const p5Setup = {
init: function () {
this.jsonInit(p5SetupJson);
// The setup block can't be removed.
this.setDeletable(false);
},
};
const p5DrawJson = {
'type': 'p5_draw',
'message0': 'draw %1',
'args0': [
{
'type': 'input_statement',
'name': 'STATEMENTS',
},
],
'colour': 300,
'tooltip': 'Draw on the canvas. This code is run continuously.',
'helpUrl': '',
};
const p5Draw = {
init: function () {
this.jsonInit(p5DrawJson);
// The draw block can't be removed.
this.setDeletable(false);
},
};
const p5CanvasJson = {
'type': 'p5_canvas',
'message0': 'create canvas with width %1 height %2',
'args0': [
{
'type': 'field_number',
'name': 'WIDTH',
'value': 400,
'max': 400,
'precision': 1,
},
{
'type': 'field_number',
'name': 'HEIGHT',
'value': 400,
'max': 400,
'precision': 1,
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 300,
'tooltip': 'Create a p5 canvas of the specified size.',
'helpUrl': '',
};
const p5Canvas = {
init: function () {
this.jsonInit(p5CanvasJson);
// The canvas block can't be moved or disconnected from its parent.
this.setMovable(false);
this.setDeletable(false);
},
};
const buttonsJson = {
'type': 'buttons',
'message0': 'If %1 %2 Then %3 %4 more %5 %6 %7',
'args0': [
{
'type': 'field_image',
'name': 'BUTTON1',
'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif',
'width': 30,
'height': 30,
'alt': '*',
},
{
'type': 'input_value',
'name': 'VALUE1',
'check': '',
},
{
'type': 'field_image',
'name': 'BUTTON2',
'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif',
'width': 30,
'height': 30,
'alt': '*',
},
{
'type': 'input_dummy',
'name': 'DUMMY1',
'check': '',
},
{
'type': 'input_value',
'name': 'VALUE2',
'check': '',
},
{
'type': 'input_statement',
'name': 'STATEMENT1',
'check': 'Number',
},
{
'type': 'field_image',
'name': 'BUTTON3',
'src': 'https://www.gstatic.com/codesite/ph/images/star_on.gif',
'width': 30,
'height': 30,
'alt': '*',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 230,
'tooltip': '',
'helpUrl': '',
};
const buttonsBlock = {
init: function () {
this.jsonInit(buttonsJson);
const clickHandler = function () {
console.log('clicking a button!');
};
this.getField('BUTTON1').setOnClickHandler(clickHandler);
this.getField('BUTTON2').setOnClickHandler(clickHandler);
this.getField('BUTTON3').setOnClickHandler(clickHandler);
},
};
const background = {
'type': 'p5_background_color',
'message0': 'Set background color to %1',
'args0': [
{
'type': 'input_value',
'name': 'COLOR',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 195,
'tooltip': 'Set the background color of the canvas',
'helpUrl': '',
};
const stroke = {
'type': 'p5_stroke',
'message0': 'Set stroke color to %1',
'args0': [
{
'type': 'input_value',
'name': 'COLOR',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 195,
'tooltip': 'Set the stroke color',
'helpUrl': '',
};
const fill = {
'type': 'p5_fill',
'message0': 'Set fill color to %1',
'args0': [
{
'type': 'input_value',
'name': 'COLOR',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 195,
'tooltip': 'Set the fill color',
'helpUrl': '',
};
const ellipse = {
'type': 'p5_ellipse',
'message0': 'draw ellipse %1 x %2 y %3 width %4 height %5',
'args0': [
{
'type': 'input_dummy',
},
{
'type': 'input_value',
'name': 'X',
'check': 'Number',
},
{
'type': 'input_value',
'name': 'Y',
'check': 'Number',
},
{
'type': 'input_value',
'name': 'WIDTH',
'check': 'Number',
},
{
'type': 'input_value',
'name': 'HEIGHT',
'check': 'Number',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 230,
'tooltip': 'Draw an ellipse on the canvas.',
'helpUrl': 'https://p5js.org/reference/#/p5/ellipse',
};
const draw_emoji = {
'type': 'draw_emoji',
'tooltip': '',
'helpUrl': '',
'message0': 'draw %1 %2',
'args0': [
{
'type': 'field_dropdown',
'name': 'emoji',
'options': [
['❤️', '❤️'],
['✨', '✨'],
['🐻', '🐻'],
],
},
{
'type': 'input_dummy',
'name': '',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 230,
'inputsInline': true,
};
const simpleCircle = {
'type': 'simple_circle',
'tooltip': '',
'helpUrl': '',
'message0': 'draw %1 circle %2',
'args0': [
{
'type': 'input_value',
'name': 'COLOR',
},
{
'type': 'input_dummy',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 230,
'inputsInline': true,
};
const writeTextWithoutShadow = {
'type': 'write_text_without_shadow',
'tooltip': '',
'helpUrl': '',
'message0': 'write without shadow %1',
'args0': [
{
'type': 'field_input',
'name': 'TEXT',
'text': 'bit',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 225,
};
const writeTextWithShadow = {
'type': 'write_text_with_shadow',
'tooltip': '',
'helpUrl': '',
'message0': 'write with shadow %1',
'args0': [
{
'type': 'input_value',
'name': 'TEXT',
'check': 'String',
},
],
'previousStatement': null,
'nextStatement': null,
'colour': 225,
};
const textBlock = {
'type': 'text_only',
'tooltip': '',
'helpUrl': '',
'message0': '%1',
'args0': [
{
'type': 'field_input',
'name': 'TEXT',
'text': 'micro',
},
],
'output': 'String',
'colour': 225,
};
// Create the block definitions for all the JSON-only blocks.
// This does not register their definitions with Blockly.
const jsonBlocks = Blockly.common.createBlockDefinitionsFromJsonArray([
background,
stroke,
fill,
ellipse,
draw_emoji,
simpleCircle,
writeTextWithoutShadow,
writeTextWithShadow,
textBlock,
]);
export const p5blocks = {
'p5_setup': p5Setup,
'p5_draw': p5Draw,
'p5_canvas': p5Canvas,
'buttons_block': buttonsBlock,
...jsonBlocks,
};