mirror of
https://github.com/google/blockly.git
synced 2026-04-27 23:50:21 +02:00
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:
Generated
+45
-86
@@ -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": {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user