diff --git a/package-lock.json b/package-lock.json index 2107250..8fbccbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,28 @@ { "name": "@mit-app-inventor/blockly-plugin-workspace-multiselect", - "version": "1.0.2", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mit-app-inventor/blockly-plugin-workspace-multiselect", - "version": "1.0.2", + "version": "2.0.0", "license": "Apache-2.0", "dependencies": { "dragselect": "^2.7.4" }, "devDependencies": { "@blockly/dev-scripts": "^4.0.9", - "@blockly/dev-tools": "^8.1.2", - "@blockly/keyboard-navigation": "^0.6.14", - "@blockly/workspace-backpack": "^6.0.16", - "blockly": "^11.2.2" + "@blockly/dev-tools": "^9.0.2", + "@blockly/keyboard-navigation": "^3.0.1", + "@blockly/workspace-backpack": "^7.0.2", + "blockly": "^12.3.0" }, "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": ">=11 <12" + "blockly": ">=12" } }, "node_modules/@asamuzakjp/css-color": { @@ -260,16 +260,16 @@ } }, "node_modules/@blockly/block-test": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-6.0.13.tgz", - "integrity": "sha512-nq9qg9/azE73nlDY6bpvUGOjSR4TrQkRtpUTEGGQNXK9TdgwbW97F6Jfx5eutnghLdouRS09Hvo9PjuyffaD0g==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.2.tgz", + "integrity": "sha512-fwbJnMiH4EoX/CR0ZTGzSKaGfpRBn4nudquoWfvG4ekkhTjaNTldDdHvUSeyexzvwZZcT6M4I1Jtq3IoomTKEg==", "dev": true, "license": "Apache 2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/dev-scripts": { @@ -309,17 +309,17 @@ } }, "node_modules/@blockly/dev-tools": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-8.1.2.tgz", - "integrity": "sha512-K54oIKbaLZV3NGC9w4FaE+MzGmlVDxyTNWO+Vuq+WrGRa3F0cons9jvzaMs1RKyHRNbMoKcHy3VzALNalG46SA==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.2.tgz", + "integrity": "sha512-Ic/+BkqEvLRZxzNQVW/FKXx1cB042xXXPTSmNlTv2qr4oY+hN2fwBtHj3PirBWAzWgMOF8VDTj/EXL36jH1/lg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@blockly/block-test": "^6.0.13", - "@blockly/theme-dark": "^7.0.12", - "@blockly/theme-deuteranopia": "^6.0.12", - "@blockly/theme-highcontrast": "^6.0.12", - "@blockly/theme-tritanopia": "^6.0.12", + "@blockly/block-test": "^7.0.2", + "@blockly/theme-dark": "^8.0.1", + "@blockly/theme-deuteranopia": "^7.0.1", + "@blockly/theme-highcontrast": "^7.0.1", + "@blockly/theme-tritanopia": "^7.0.1", "chai": "^4.2.0", "dat.gui": "^0.7.7", "lodash.assign": "^4.2.0", @@ -331,7 +331,7 @@ "node": ">=8.0.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/eslint-config": { @@ -356,81 +356,78 @@ } }, "node_modules/@blockly/keyboard-navigation": { - "version": "0.6.14", - "resolved": "https://registry.npmjs.org/@blockly/keyboard-navigation/-/keyboard-navigation-0.6.14.tgz", - "integrity": "sha512-CBPGQWZxaLvFkDJoa/bh5CYmC1K399ERBt0jmdowhcsk4mGcTDbdifAa71gke3hD8tviIPU0vBEZiSdDzSFlpA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@blockly/keyboard-navigation/-/keyboard-navigation-3.0.1.tgz", + "integrity": "sha512-qSOPqsqRgkSLEoUeEZc81PWe558pXqY0e+4jkRODoAD+I1hMpCoD+6ivveRp7Jpb8WE1lj2PrAFOVuIVpphjHA==", "dev": true, "license": "Apache-2.0", - "engines": { - "node": ">=8.17.0" - }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.3.0" } }, "node_modules/@blockly/theme-dark": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-7.0.12.tgz", - "integrity": "sha512-DbRiiwSfVTAbAISnCQ1ismCxqc0lnSvtPL4t526X/m5WsLlMh0GT09ivDcP4ajXyWV4yk70wkhv3RYhZkmXuAQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-8.0.1.tgz", + "integrity": "sha512-0Di3WIUwCVQw7jK9myUf/J+4oHLADWc8YxeF40KQgGsyulVrVnYipwtBolj+wxq2xjxIkqgvctAN3BdvM4mynA==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-deuteranopia": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-6.0.12.tgz", - "integrity": "sha512-4mlt36kwlaYi0j9IzmPmJTb2XsaOL3bLvHQVTpqoCqFivYY56VpNudZU9aGmknvhH0swNO5VXFrvCcPgWyrruQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-7.0.1.tgz", + "integrity": "sha512-V05Hk2hzQZict47LfzDdSTP+J5HlYiF7de/8LR/bsRQB/ft7UUTraqDLIivYc9gL2alsVtKzq/yFs9wi7FMAqQ==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-highcontrast": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-6.0.12.tgz", - "integrity": "sha512-n//Cg7cJFC8A7FXiwoxuoYorFcKk6MQqyUpYZsr2iQI3XMrqqgn0XAyEyvIZRgcqeoRK5g8wSDNiDnGC94McBw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-7.0.1.tgz", + "integrity": "sha512-dMhysbXf8QtHxuhI1EY5GdZErlfEhjpCogwfzglDKSu8MF2C+5qzOQBxKmqfnEYJl6G9B2HNGw+mEaUo8oel6Q==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/theme-tritanopia": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-6.0.12.tgz", - "integrity": "sha512-KYpKJiGKNy7w4/Nm7oBYe7ezSK/PRGZPh7pDbC+In6OyQm5CXCKaG6EVmA0tgbJ8urJusyOydlPfTFj1QIIO8g==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-7.0.1.tgz", + "integrity": "sha512-eLqPCmW6xvSYvyTFFE5uz0Bw806LxOmaQrCOzbUywkT41s2ITP06OP1BVQrHdkZSt5whipZYpB1RMGxYxS/Bpw==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@blockly/workspace-backpack": { - "version": "6.0.16", - "resolved": "https://registry.npmjs.org/@blockly/workspace-backpack/-/workspace-backpack-6.0.16.tgz", - "integrity": "sha512-jZZgThFkGEBGYcBtWzMTTFE3XkepqIBTTcLVKODhw5zPNABtGRwcys4wh/I7c925Wbbm+pKfDa3XdzjbgkQGVQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@blockly/workspace-backpack/-/workspace-backpack-7.0.2.tgz", + "integrity": "sha512-1QRI/mHWtzWYWPy+otrbajSTA8DZ6r1Q1jfHJUpl0UyKsRwx79a/XQKdWvtJVTJdRji4yTQVj2fPWNv3ING5mQ==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^11.0.0" + "blockly": "^12.0.0" } }, "node_modules/@csstools/color-helpers": { @@ -1709,13 +1706,6 @@ "node": ">=8" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/babel-eslint": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", @@ -1796,13 +1786,13 @@ } }, "node_modules/blockly": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/blockly/-/blockly-11.2.2.tgz", - "integrity": "sha512-YJW9jMz4qoBXzOOUqxWBOHL35QhOOTAg2a6sVAFJh+/uNTXW0z506JS7qkqZI4PQ9fnUhDqmhF8a0qWSTL1tjg==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/blockly/-/blockly-12.3.0.tgz", + "integrity": "sha512-dtxM6Dk8cm0QW4vMJTXmn7xi7a4GnQdXu28Esuuofx7DsYfq73456O5tm3ShUMDcXaFg8w3GVfgoH8I9v6gSVA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "jsdom": "25.0.1" + "jsdom": "26.1.0" }, "engines": { "node": ">=18" @@ -2198,19 +2188,6 @@ "dev": true, "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -2387,13 +2364,6 @@ "node": ">=18" } }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, "node_modules/dat.gui": { "version": "0.7.9", "resolved": "https://registry.npmjs.org/dat.gui/-/dat.gui-0.7.9.tgz", @@ -2526,16 +2496,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2779,22 +2739,6 @@ "node": ">= 0.4" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3500,23 +3444,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3815,22 +3742,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4430,31 +4341,30 @@ } }, "node_modules/jsdom": { - "version": "25.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", - "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", "dependencies": { - "cssstyle": "^4.1.0", + "cssstyle": "^4.2.1", "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", + "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.5", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.12", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.7.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^5.0.0", + "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", + "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, @@ -4462,7 +4372,7 @@ "node": ">=18" }, "peerDependencies": { - "canvas": "^2.11.2" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -5753,9 +5663,9 @@ } }, "node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index dc43aa2..8295163 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mit-app-inventor/blockly-plugin-workspace-multiselect", - "version": "1.0.2", + "version": "2.0.0", "description": "A Blockly plugin that allows to drag, select and doing actions on multiple blocks in the workspace.", "scripts": { "audit:fix": "blockly-scripts auditFix", @@ -43,13 +43,13 @@ }, "devDependencies": { "@blockly/dev-scripts": "^4.0.9", - "@blockly/dev-tools": "^8.1.2", - "@blockly/keyboard-navigation": "^0.6.14", - "@blockly/workspace-backpack": "^6.0.16", - "blockly": "^11.2.2" + "@blockly/dev-tools": "^9.0.2", + "@blockly/keyboard-navigation": "^3.0.1", + "@blockly/workspace-backpack": "^7.0.2", + "blockly": "^12.3.0" }, "peerDependencies": { - "blockly": ">=11 <12" + "blockly": ">=12" }, "publishConfig": {}, "eslintConfig": { diff --git a/src/DOM_FOCUS_IMPLEMENTATION.md b/src/DOM_FOCUS_IMPLEMENTATION.md new file mode 100644 index 0000000..58cdbc8 --- /dev/null +++ b/src/DOM_FOCUS_IMPLEMENTATION.md @@ -0,0 +1,61 @@ +# DOM-Based Focus Management Implementation + +This implementation follows Christopher's suggestion to create a visible DOM element for the MultiselectDraggable that can hold browser focus, addressing the focus management issues in Blockly v12. + +## Key Changes + +### 1. Visual Selection Outline +- Added a rectangular outline around all selected items (similar to Google Slides) +- The outline is a visible SVG element that can receive browser focus +- Styled with dashed border and proper focus indicators + +### 2. DOM Focus Management +- `getFocusableElement()` now returns the actual DOM element (`selectionOutline_`) +- Added proper `tabindex="0"` and accessibility attributes +- Implemented keyboard event handling for accessibility + +### 3. Real-time Updates +- Outline position and size updates automatically when: + - Items are added/removed from selection + - Items are dragged + - Selection changes +- Visual feedback when gaining/losing focus + +### 4. Integration with Existing System +- All existing `updateFocusedNode()` calls now trigger outline visibility +- Added `onBecomeFocused()` method to ensure outline is shown +- Proper cleanup in `dispose()` method + +## Technical Details + +### Visual Elements +- **Container**: `.blocklyMultiselectOutline` - Transparent container +- **Outline**: `.blocklyMultiselectOutlineRect` - Focusable dashed rectangle +- **Styling**: Blue dashed border (#4285f4) with rounded corners + +### Focus States +- **Normal**: 2px dashed border +- **Focused**: 3px border with enhanced visibility +- **Hover**: Reduced opacity for better UX + +### Accessibility +- Proper `role="button"` and `aria-label` +- Keyboard navigation support (Enter, Space, Escape) +- Focus indicators follow web accessibility standards + +## Benefits + +1. **Solves v12 Focus Issues**: Provides a real DOM element that focus manager can work with +2. **Visual Feedback**: Users can clearly see what's selected +3. **Accessibility**: Screen readers and keyboard navigation work properly +4. **Backward Compatible**: Doesn't break existing functionality + +## Usage + +The implementation is automatic - when multiple blocks are selected: +1. The outline appears around the selection +2. The outline can receive focus from the focus manager +3. Users can interact with it via keyboard or mouse +4. The outline updates in real-time during operations + +This follows the recommended approach (2) from Ben's suggestions and implements Christopher's DOM element idea successfully. diff --git a/src/multiselect.js b/src/multiselect.js index e311ec5..88b48ae 100644 --- a/src/multiselect.js +++ b/src/multiselect.js @@ -441,7 +441,7 @@ export class Multiselect { 'blocklyDropdownMenu') > -1)) { this.controls_.revertLastUnselectedBlock(); } - this.controls_.disableMultiselect(); + // this.controls_.disableMultiselect(); } } } diff --git a/src/multiselect_contextmenu.js b/src/multiselect_contextmenu.js index efc8d41..cefc3b7 100644 --- a/src/multiselect_contextmenu.js +++ b/src/multiselect_contextmenu.js @@ -210,7 +210,7 @@ const registerDuplicate = function() { }); dragSelection.clear(); multiDraggable.clearAll_(); - Blockly.common.setSelected(null); + Blockly.common.setSelected(workspace); } else { apply(scope.block); } @@ -234,7 +234,11 @@ const registerDuplicate = function() { connectionDBList.forEach(function(connectionDB) { connectionDB[0].connect(connectionDB[1]); }); - Blockly.common.setSelected(multiDraggable); + Blockly.getFocusManager().updateFocusedNode(multiDraggable); + // Call the new method to ensure outline is visible + if (multiDraggable.onBecomeFocused) { + multiDraggable.onBecomeFocused(); + } Blockly.Events.setGroup(false); }, scopeType: Blockly.ContextMenuRegistry.ScopeType.BLOCK, @@ -801,7 +805,11 @@ const registerPaste = function(useCopyPasteCrossTab) { blockList[connectionDB[1]].previousConnection); }); Blockly.Events.setGroup(false); - Blockly.common.setSelected(multiDraggable); + Blockly.getFocusManager().updateFocusedNode(multiDraggable); + // Call the new method to ensure outline is visible + if (multiDraggable.onBecomeFocused) { + multiDraggable.onBecomeFocused(); + } return true; }, scopeType: Blockly.ContextMenuRegistry.ScopeType.WORKSPACE, @@ -845,7 +853,7 @@ const registerSelectAll = function() { } else { Blockly.getSelected().unselect(); } - Blockly.common.setSelected(null); + Blockly.common.setSelected(scope.workspace); multiDraggable.clearAll_(); dragSelectionWeakMap.get(scope.workspace).clear(); } @@ -868,7 +876,11 @@ const registerSelectAll = function() { } }); - Blockly.common.setSelected(multiDraggable); + Blockly.getFocusManager().updateFocusedNode(multiDraggable); + // Call the new method to ensure outline is visible + if (multiDraggable.onBecomeFocused) { + multiDraggable.onBecomeFocused(); + } }, scopeType: Blockly.ContextMenuRegistry.ScopeType.WORKSPACE, id, @@ -1135,7 +1147,7 @@ const registerCommentDuplicate = function() { }); dragSelection.clear(); multiDraggable.clearAll_(); - Blockly.common.setSelected(null); + Blockly.common.setSelected(workspace); } else { apply(scope.comment); } @@ -1147,7 +1159,11 @@ const registerCommentDuplicate = function() { comment.select(); } } - Blockly.common.setSelected(multiDraggable); + Blockly.getFocusManager().updateFocusedNode(multiDraggable); + // Call the new method to ensure outline is visible + if (multiDraggable.onBecomeFocused) { + multiDraggable.onBecomeFocused(); + } Blockly.Events.setGroup(false); }, scopeType: Blockly.ContextMenuRegistry.ScopeType.COMMENT, diff --git a/src/multiselect_controls.js b/src/multiselect_controls.js index 2fd5054..3c07a2b 100644 --- a/src/multiselect_controls.js +++ b/src/multiselect_controls.js @@ -387,7 +387,7 @@ export class MultiselectControls { } this.multiDraggable.clearAll_(); this.dragSelection.clear(); - Blockly.common.setSelected(null); + Blockly.common.setSelected(this.workspace_); } else if (Blockly.getSelected() && !(Blockly.getSelected() instanceof MultiselectDraggable)) { // Blockly.getSelected() is not a multiselectDraggable @@ -410,7 +410,11 @@ export class MultiselectControls { // Set selected to multiDraggable if dragSelection not empty if (this.dragSelection.size && !(Blockly.getSelected() instanceof MultiselectDraggable)) { - Blockly.common.setSelected(this.multiDraggable); + Blockly.getFocusManager().updateFocusedNode(this.multiDraggable); + // Call the new method to ensure outline is visible + if (this.multiDraggable.onBecomeFocused) { + this.multiDraggable.onBecomeFocused(); + } } else if (this.lastSelectedElement_ && !inPasteShortcut.get(this.workspace_)) { this.updateDraggables_(this.lastSelectedElement_); @@ -420,7 +424,7 @@ export class MultiselectControls { MultiselectDraggable)) { if (Blockly.getSelected() instanceof Blockly.BlockSvg && !Blockly.getSelected().isShadow()) { - Blockly.common.setSelected(null); + Blockly.common.setSelected(this.workspace_); } // TODO: Look into this after gesture has been updated at Blockly // Currently, the setSelected is called twice even with selection of @@ -533,7 +537,11 @@ export class MultiselectControls { // Ensure that Blockly selects multidraggable if // our set is not empty. if (this.dragSelection.size && !Blockly.getSelected()) { - Blockly.common.setSelected(this.multiDraggable); + Blockly.getFocusManager().updateFocusedNode(this.multiDraggable); + // Call the new method to ensure outline is visible + if (this.multiDraggable.onBecomeFocused) { + this.multiDraggable.onBecomeFocused(); + } } if (this.hasDisableWorkspaceDrag_) { this.workspace_.options.moveOptions.drag = true; @@ -576,4 +584,22 @@ Blockly.Css.register(` .blocklyMultiselect>image:active, .blocklyMultiselect>svg>image:active { opacity: .8; } + +.blocklyMultiselectOutline { + pointer-events: none; +} + +.blocklyMultiselectOutlineRect { + pointer-events: all; + cursor: move; +} + +.blocklyMultiselectOutlineRect:focus { + outline: none; + stroke-width: 3; +} + +.blocklyMultiselectOutlineRect:hover { + stroke-opacity: 0.8; +} `); diff --git a/src/multiselect_draggable.js b/src/multiselect_draggable.js index e7b6e69..691da79 100644 --- a/src/multiselect_draggable.js +++ b/src/multiselect_draggable.js @@ -30,6 +30,69 @@ export class MultiselectDraggable { this.loc = new Blockly.utils.Coordinate(0, 0); this.connectionDBList = []; this.dragSelection = dragSelectionWeakMap.get(workspace); + + // Create DOM element for focus management + this.createSelectionOutline_(); + } + + /** + * Determines if this draggable can be focused. + * @returns {boolean} Always returns true. + */ + canBeFocused() { + return true; + } + + /** + * Gets the focusable element for this draggable. + * @returns {Element} The DOM element that can hold focus. + */ + getFocusableElement() { + return this.selectionOutline_; + } + + /** + * Gets the focusable tree for this draggable. + * @returns {Blockly.Workspace} The workspace containing this draggable. + */ + getFocusableTree() { + return this.workspace; + } + + /** + * Gets the SVG root element for this draggable. + * @returns {Element} The selection outline element. + */ + getSvgRoot() { + return this.selectionOutline_; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus() { + // Highlight all selected blocks when the multiselect outline gains focus + for (const id of this.dragSelection) { + const block = this.workspace.getBlockById(id); + if (block) { + block.addSelect(); + } + } + + // Ensure the outline is visible and highlighted + this.updateSelectionOutline_(); + } + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur() { + // Remove highlight from selected blocks when focus is lost + // (but only if we're not in multiselect mode) + if (!inMultipleSelectionModeWeakMap.get(this.workspace)) { + for (const id of this.dragSelection) { + const block = this.workspace.getBlockById(id); + if (block) { + block.removeSelect(); + } + } + } } /** @@ -41,6 +104,115 @@ export class MultiselectDraggable { subDraggable.unselect(); this.removeSubDraggable_(subDraggable); } + this.hideSelectionOutline_(); + } + + /** + * Creates a visual selection outline DOM element that can hold focus. + * This outline appears around all selected items like in Google Slides. + * @private + */ + createSelectionOutline_() { + // Create the selection outline group + this.selectionOutlineGroup_ = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.G, + { + 'class': 'blocklyMultiselectOutline', + 'style': 'pointer-events: none;' + }, + this.workspace.getCanvas() + ); + + // Create the actual outline rectangle + this.selectionOutline_ = Blockly.utils.dom.createSvgElement( + Blockly.utils.Svg.RECT, + { + 'class': 'blocklyMultiselectOutlineRect', + 'fill': 'none', + 'stroke': '#4285f4', + 'stroke-width': '2', + 'stroke-dasharray': '5,5', + 'rx': '4', + 'ry': '4', + 'style': 'pointer-events: all;', + 'tabindex': '0', + 'role': 'button', + 'aria-label': 'Multiple selected blocks' + }, + this.selectionOutlineGroup_ + ); + + // Initially hide the outline + this.hideSelectionOutline_(); + + // Add focus/blur event listeners + this.selectionOutline_.addEventListener('focus', this.onNodeFocus.bind(this)); + this.selectionOutline_.addEventListener('blur', this.onNodeBlur.bind(this)); + + // Add keyboard event handling for accessibility + this.selectionOutline_.addEventListener('keydown', (e) => { + // Allow keyboard navigation and interaction + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + // Toggle selection or perform action + } else if (e.key === 'Escape') { + e.preventDefault(); + // Clear selection + this.workspace.getFocusManager().updateFocusedNode(null); + } + }); + } + + /** + * Updates the position and size of the selection outline to encompass + * all selected items. + * @private + */ + updateSelectionOutline_() { + if (this.subDraggables.size === 0) { + this.hideSelectionOutline_(); + return; + } + + const bounds = this.getBoundingRectangle(); + if (!bounds || bounds.left === Infinity) { + this.hideSelectionOutline_(); + return; + } + + // Add padding around the selection + const padding = 8; + const x = bounds.left - padding; + const y = bounds.top - padding; + const width = bounds.right - bounds.left + (padding * 2); + const height = bounds.bottom - bounds.top + (padding * 2); + + this.selectionOutline_.setAttribute('x', x); + this.selectionOutline_.setAttribute('y', y); + this.selectionOutline_.setAttribute('width', width); + this.selectionOutline_.setAttribute('height', height); + + this.showSelectionOutline_(); + } + + /** + * Shows the selection outline. + * @private + */ + showSelectionOutline_() { + if (this.selectionOutlineGroup_) { + this.selectionOutlineGroup_.style.display = 'block'; + } + } + + /** + * Hides the selection outline. + * @private + */ + hideSelectionOutline_() { + if (this.selectionOutlineGroup_) { + this.selectionOutlineGroup_.style.display = 'none'; + } } /** @@ -54,6 +226,9 @@ export class MultiselectDraggable { this.addPointerDownEventListener_(subDraggable); } this.subDraggables.set(subDraggable, subDraggable.getRelativeToSurfaceXY()); + + // Update the visual outline when items are added + this.updateSelectionOutline_(); } /** @@ -67,18 +242,22 @@ export class MultiselectDraggable { this.removePointerDownEventListener_(subDraggable); } this.subDraggables.delete(subDraggable); + + // Update the visual outline when items are removed + this.updateSelectionOutline_(); } // This is the feature where we added a pointer down event listener. // This was added to mitigate the issue of setStart[draggable] overwriting - // the call that passes the multidraggable to Blockly.common.SetSelected(). + // the call that passes the multidraggable to + // Blockly.getFocusManager().updateFocusedNode(). // This should be updated/fixed when a more flexible gesture handling // system is implemented. // TODO: Look into these after gestures have been updated /** * Adds a pointer down event listener to a subdraggable to mitigate issue * of setStart[draggable] overwriting the call that passes the - * multidraggable to Blockly.common.SetSelected(). + * multidraggable to Blockly.getFocusManager().updateFocusedNode(). * @param {Blockly.IDraggable} subDraggable A draggable object that will * have an event listener added to * @private @@ -101,7 +280,8 @@ export class MultiselectDraggable { /** * Removes a pointer down event listener from a subdraggable to * mitigate issue of setStart[draggable] overwriting the call that - * passes the multidraggable to Blockly.common.SetSelected(). + * passes the multidraggable to + * Blockly.getFocusManager().updateFocusedNode(). * @param {Blockly.IDraggable} subDraggable A draggable object * that will have an event listener removed from * @private @@ -122,13 +302,17 @@ export class MultiselectDraggable { /** * The handler for the pointer down event that mitigates * the issue of setStart[draggable] overwriting the call that - * passes the multidraggable to Blockly.common.SetSelected(). + * passes the multidraggable to Blockly.getFocusManager().updateFocusedNode(). * @param {PointerEvent} event A pointer down event * @private */ pointerDownEventHandler_(event) { if (!inMultipleSelectionModeWeakMap.get(this.workspace)) { - Blockly.common.setSelected(this); + Blockly.getFocusManager().updateFocusedNode(this); + const gesture = this.workspace.getGesture(event); + if (gesture) { + gesture.startBlock = this; + } } } @@ -214,6 +398,9 @@ export class MultiselectDraggable { this.subDraggables.get(draggable)), e); } } + + // Update the outline position during drag + this.updateSelectionOutline_(); } /** @@ -243,6 +430,9 @@ export class MultiselectDraggable { if (!this.inGroup) { Blockly.Events.setGroup(false); } + + // Update the outline position after drag ends + this.updateSelectionOutline_(); } /** @@ -266,13 +456,11 @@ export class MultiselectDraggable { // This needs to be worked on to see if we can make the // highlighting of the subdraggables in real time. for (const draggable of this.subDraggables) { - if (draggable[0] instanceof Blockly.BlockSvg && - !draggable[0].isShadow()) { - draggable[0].select(); - } else { - draggable[0].select(); - } + draggable[0].select(); } + + // Update and show the outline when selected + this.updateSelectionOutline_(); } /** @@ -286,6 +474,21 @@ export class MultiselectDraggable { // for (const draggable of this.subDraggables) { // draggable[0].unselect(); // } + + // Hide the outline when unselected + this.hideSelectionOutline_(); + } + + /** + * Called when this multiselect draggable becomes the focused node. + * Updates the outline and ensures it's visible. + */ + onBecomeFocused() { + this.updateSelectionOutline_(); + if (this.selectionOutline_) { + // Programmatically focus the outline element + this.selectionOutline_.focus(); + } } @@ -315,6 +518,13 @@ export class MultiselectDraggable { draggable[0].dispose(); } } + + // Clean up the selection outline DOM elements + if (this.selectionOutlineGroup_) { + Blockly.utils.dom.removeNode(this.selectionOutlineGroup_); + this.selectionOutlineGroup_ = null; + this.selectionOutline_ = null; + } } /** diff --git a/src/multiselect_shortcut.js b/src/multiselect_shortcut.js index a6ad040..305b219 100644 --- a/src/multiselect_shortcut.js +++ b/src/multiselect_shortcut.js @@ -433,7 +433,11 @@ const registerPaste = function(useCopyPasteCrossTab) { blockList[connectionDB[1]].previousConnection); }); - Blockly.common.setSelected(multiDraggable); + Blockly.getFocusManager().updateFocusedNode(multiDraggable); + // Call the new method to ensure outline is visible + if (multiDraggable.onBecomeFocused) { + multiDraggable.onBecomeFocused(); + } Blockly.Events.setGroup(false); return true; }, @@ -493,7 +497,7 @@ const registerSelectAll = function() { } else { Blockly.getSelected().unselect(); } - Blockly.common.setSelected(null); + Blockly.common.setSelected(workspace); multiDraggable.clearAll_(); dragSelectionWeakMap.get(workspace).clear(); } @@ -516,7 +520,11 @@ const registerSelectAll = function() { } }); - Blockly.common.setSelected(multiDraggable); + Blockly.getFocusManager().updateFocusedNode(multiDraggable); + // Call the new method to ensure outline is visible + if (multiDraggable.onBecomeFocused) { + multiDraggable.onBecomeFocused(); + } return true; }, }; diff --git a/test/index.js b/test/index.js index c96ec1a..a274cc1 100644 --- a/test/index.js +++ b/test/index.js @@ -12,9 +12,9 @@ import * as Blockly from 'blockly'; import {toolboxCategories, createPlayground} from '@blockly/dev-tools'; import {Multiselect} from '../src/index'; import {Backpack} from '@blockly/workspace-backpack'; -import {NavigationController} from '@blockly/keyboard-navigation'; +// import {NavigationController} from '@blockly/keyboard-navigation'; -const navigationController = new NavigationController(); +// const navigationController = new NavigationController(); /** * Create a workspace. * @param {HTMLElement} blocklyDiv The blockly container div. @@ -28,7 +28,7 @@ function createWorkspace(blocklyDiv, options) { const backpack = new Backpack(workspace); backpack.init(); - navigationController.addWorkspace(workspace); + // navigationController.addWorkspace(workspace); // Initialize multiselect plugin. const multiselectPlugin = new Multiselect(workspace); @@ -38,8 +38,8 @@ function createWorkspace(blocklyDiv, options) { } Blockly.ContextMenuItems.registerCommentOptions(); -// Initialize keyboard nav plugin. -navigationController.init(); +// // Initialize keyboard nav plugin. +// navigationController.init(); document.addEventListener('DOMContentLoaded', function() { const defaultOptions = {