diff --git a/plugins/block-dynamic-connection/README.md b/plugins/block-dynamic-connection/README.md index 6fb7f62d7b..50a7d69d47 100644 --- a/plugins/block-dynamic-connection/README.md +++ b/plugins/block-dynamic-connection/README.md @@ -14,6 +14,18 @@ npm install @blockly/block-dynamic-connection --save ```js import * as Blockly from 'blockly'; import * as BlockDynamicConnection from '@blockly/block-dynamic-connection'; + +const ws = Blockly.inject({ + // options... + plugins: { + connectionPreviewer: + BlockDynamicConnection.decoratePreviewerWithDynamicConnections( + // Replace with a custom connection previewer, or remove to decorate + // the default one. + Blockly.InsertionMarkerPreviewer, + ), + }, + }; ``` ## API diff --git a/plugins/block-dynamic-connection/package-lock.json b/plugins/block-dynamic-connection/package-lock.json index aa6a72abee..8d7edb11a8 100644 --- a/plugins/block-dynamic-connection/package-lock.json +++ b/plugins/block-dynamic-connection/package-lock.json @@ -1,15 +1,15 @@ { "name": "@blockly/block-dynamic-connection", - "version": "0.5.6", + "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@blockly/block-dynamic-connection", - "version": "0.5.6", + "version": "1.0.0", "license": "Apache-2.0", "devDependencies": { - "blockly": "^10.2.0", + "blockly": "^10.4.0-beta.1", "chai": "^4.2.0", "mocha": "^10.2.0", "typescript": "^5.2.2" @@ -18,7 +18,7 @@ "node": ">=8.17.0" }, "peerDependencies": { - "blockly": "^10.2.0" + "blockly": "^10.4.0-beta.1" } }, "node_modules/@tootallnate/once": { @@ -116,9 +116,9 @@ } }, "node_modules/blockly": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/blockly/-/blockly-10.2.0.tgz", - "integrity": "sha512-9jPGdiU3/pzyc2abyqoWcG+ddQwLBixb8HHr5QH2oSn+HialbaR94hS0hqgKXptMr7eD5/ZwEqVK+ptq8Gxe0A==", + "version": "10.4.0-beta.1", + "resolved": "https://registry.npmjs.org/blockly/-/blockly-10.4.0-beta.1.tgz", + "integrity": "sha512-+/9IO6ugkTJ4VY0Vp2Zbk0cbFEdZ5fPcDE9CMztjJng0Oe5NU9o4e4iRAC/tmpo/R7QRSARPbSW49/JR8uepgg==", "dev": true, "dependencies": { "jsdom": "22.1.0" @@ -1556,9 +1556,9 @@ "dev": true }, "blockly": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/blockly/-/blockly-10.2.0.tgz", - "integrity": "sha512-9jPGdiU3/pzyc2abyqoWcG+ddQwLBixb8HHr5QH2oSn+HialbaR94hS0hqgKXptMr7eD5/ZwEqVK+ptq8Gxe0A==", + "version": "10.4.0-beta.1", + "resolved": "https://registry.npmjs.org/blockly/-/blockly-10.4.0-beta.1.tgz", + "integrity": "sha512-+/9IO6ugkTJ4VY0Vp2Zbk0cbFEdZ5fPcDE9CMztjJng0Oe5NU9o4e4iRAC/tmpo/R7QRSARPbSW49/JR8uepgg==", "dev": true, "requires": { "jsdom": "22.1.0" diff --git a/plugins/block-dynamic-connection/package.json b/plugins/block-dynamic-connection/package.json index 8c3e588c32..a4753975f8 100644 --- a/plugins/block-dynamic-connection/package.json +++ b/plugins/block-dynamic-connection/package.json @@ -1,6 +1,6 @@ { "name": "@blockly/block-dynamic-connection", - "version": "0.5.6", + "version": "1.0.0", "description": "A group of blocks that add connections dynamically.", "scripts": { "audit:fix": "blockly-scripts auditFix", @@ -42,13 +42,13 @@ "devDependencies": { "@blockly/dev-scripts": "^3.1.1", "@blockly/dev-tools": "^7.1.5", - "blockly": "^10.2.0", + "blockly": "^10.4.0-beta.1", "chai": "^4.2.0", "mocha": "^10.2.0", "typescript": "^5.2.2" }, "peerDependencies": { - "blockly": "^10.2.0" + "blockly": "^10.4.0-beta.1" }, "publishConfig": { "access": "public", diff --git a/plugins/block-dynamic-connection/src/connection_previewer.ts b/plugins/block-dynamic-connection/src/connection_previewer.ts new file mode 100644 index 0000000000..ec7e0612fa --- /dev/null +++ b/plugins/block-dynamic-connection/src/connection_previewer.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +interface ConnectionPreviewerConstructor { + new (draggedBlock: Blockly.BlockSvg): Blockly.IConnectionPreviewer; +} + +interface DynamicBlock extends Blockly.BlockSvg { + onPendingConnection(connection: Blockly.RenderedConnection): void; + finalizeConnections(): void; +} + +function blockIsDynamic(block: Blockly.BlockSvg): block is DynamicBlock { + return ( + (block as DynamicBlock)['onPendingConnection'] !== undefined && + (block as DynamicBlock)['finalizeConnections'] !== undefined + ); +} + +/** + * Returns a connection previewer constructor that decorates the passed + * constructor to add connection previewing. + * + * @param BasePreviewerConstructor The constructor for the base connection + * previewer class being decorated. If not provided, the default + * InsertionMarkerPreviewer will be used. + * @return A decorated connection previewer constructor. + */ +export function decoratePreviewer( + BasePreviewerConstructor?: ConnectionPreviewerConstructor, +): ConnectionPreviewerConstructor { + return class implements Blockly.IConnectionPreviewer { + private basePreviewer: Blockly.IConnectionPreviewer; + + private pendingBlocks: Set = new Set(); + + constructor(draggedBlock: Blockly.BlockSvg) { + const BaseConstructor = + BasePreviewerConstructor ?? Blockly.InsertionMarkerPreviewer; + this.basePreviewer = new BaseConstructor(draggedBlock); + } + + previewReplacement( + draggedConn: Blockly.RenderedConnection, + staticConn: Blockly.RenderedConnection, + replacedBlock: Blockly.BlockSvg, + ): void { + this.previewDynamism(staticConn); + this.basePreviewer.previewReplacement( + draggedConn, + staticConn, + replacedBlock, + ); + } + + previewConnection( + draggedConn: Blockly.RenderedConnection, + staticConn: Blockly.RenderedConnection, + ): void { + this.previewDynamism(staticConn); + this.basePreviewer.previewConnection(draggedConn, staticConn); + } + + hidePreview(): void { + this.basePreviewer.hidePreview(); + } + + dispose(): void { + for (const block of this.pendingBlocks) { + if (block.isDeadOrDying()) return; + block.finalizeConnections(); + } + this.pendingBlocks.clear(); + this.basePreviewer.dispose(); + } + + /** + * If the block is a dynamic block, calls onPendingConnection and + * stores the block to be finalized later. + * + * @param conn The block to trigger onPendingConnection on. + */ + private previewDynamism(conn: Blockly.RenderedConnection) { + const block = conn.getSourceBlock(); + if (blockIsDynamic(block)) { + block.onPendingConnection(conn); + this.pendingBlocks.add(block); + } + } + }; +} diff --git a/plugins/block-dynamic-connection/src/dynamic_if.ts b/plugins/block-dynamic-connection/src/dynamic_if.ts index ee77b0b684..5090ada2d6 100644 --- a/plugins/block-dynamic-connection/src/dynamic_if.ts +++ b/plugins/block-dynamic-connection/src/dynamic_if.ts @@ -162,12 +162,14 @@ const DYNAMIC_IF_MIXIN = { * @returns The state of this block, ie the else if count and else state. */ saveExtraState: function (this: DynamicIfBlock): IfExtraState | null { - // If we call finalizeConnections here without disabling events, we get into - // an event loop. - Blockly.Events.disable(); - this.finalizeConnections(); - if (this instanceof Blockly.BlockSvg) this.initSvg(); - Blockly.Events.enable(); + if (!this.isCorrectlyFormatted()) { + // If we call finalizeConnections here without disabling events, we get into + // an event loop. + Blockly.Events.disable(); + this.finalizeConnections(); + if (this instanceof Blockly.BlockSvg) this.initSvg(); + Blockly.Events.enable(); + } if (!this.elseifCount && !this.elseCount) { return null; @@ -218,6 +220,13 @@ const DYNAMIC_IF_MIXIN = { this: DynamicIfBlock, connection: Blockly.Connection, ): number | null { + if ( + !connection.targetConnection || + connection.targetBlock()?.isInsertionMarker() + ) { + // This connection is available. + return null; + } for (let i = 0; i < this.inputList.length; i++) { const input = this.inputList[i]; if (input.connection == connection) { @@ -270,7 +279,7 @@ const DYNAMIC_IF_MIXIN = { return; } const input = this.inputList[inputIndex]; - if (connection.targetConnection && input.name.includes('IF')) { + if (input.name.includes('IF')) { const nextIfInput = this.inputList[inputIndex + 2]; if (!nextIfInput || nextIfInput.name == 'ELSE') { this.insertElseIf(inputIndex + 2, Blockly.utils.idGenerator.genUid()); @@ -381,6 +390,18 @@ const DYNAMIC_IF_MIXIN = { Blockly.Msg['CONTROLS_IF_MSG_THEN'], ); }, + + /** + * Returns true if all of the inputs on this block are in order. + * False otherwise. + */ + isCorrectlyFormatted(this: DynamicIfBlock): boolean { + for (let i = 0; i < this.inputList.length - 1; i += 2) { + if (this.inputList[i].name !== `IF${i}`) return false; + if (this.inputList[i + 1].name !== `DO${i}`) return false; + } + return true; + }, }; Blockly.Blocks['dynamic_if'] = DYNAMIC_IF_MIXIN; diff --git a/plugins/block-dynamic-connection/src/dynamic_list_create.ts b/plugins/block-dynamic-connection/src/dynamic_list_create.ts index c911abb87f..dc48ce792e 100644 --- a/plugins/block-dynamic-connection/src/dynamic_list_create.ts +++ b/plugins/block-dynamic-connection/src/dynamic_list_create.ts @@ -112,12 +112,14 @@ const DYNAMIC_LIST_CREATE_MIXIN = { * @returns The state of this block, ie the item count. */ saveExtraState: function (this: DynamicListCreateBlock): {itemCount: number} { - // If we call finalizeConnections here without disabling events, we get into - // an event loop. - Blockly.Events.disable(); - this.finalizeConnections(); - if (this instanceof Blockly.BlockSvg) this.initSvg(); - Blockly.Events.enable(); + if (!this.isCorrectlyFormatted()) { + // If we call finalizeConnections here without disabling events, we get + // into an event loop. + Blockly.Events.disable(); + this.finalizeConnections(); + if (this instanceof Blockly.BlockSvg) this.initSvg(); + Blockly.Events.enable(); + } return { itemCount: this.itemCount, @@ -156,8 +158,11 @@ const DYNAMIC_LIST_CREATE_MIXIN = { this: DynamicListCreateBlock, connection: Blockly.Connection, ): number | null { - if (!connection.targetConnection) { - // this connection is available + if ( + !connection.targetConnection || + connection.targetBlock()?.isInsertionMarker() + ) { + // This connection is available. return null; } @@ -169,7 +174,7 @@ const DYNAMIC_LIST_CREATE_MIXIN = { } if (connectionIndex == this.inputList.length - 1) { - // this connection is the last one and already has a block in it, so + // This connection is the last one and already has a block in it, so // we should add a new connection at the end. return this.inputList.length + 1; } @@ -183,7 +188,7 @@ const DYNAMIC_LIST_CREATE_MIXIN = { return connectionIndex + 1; } - // Don't add new connection + // Don't add new connection. return null; }, @@ -281,6 +286,17 @@ const DYNAMIC_LIST_CREATE_MIXIN = { Blockly.Msg['LISTS_CREATE_WITH_INPUT_WITH'], ); }, + + /** + * Returns true if all of the inputs on this block are in order. + * False otherwise. + */ + isCorrectlyFormatted(this: DynamicListCreateBlock): boolean { + for (let i = 0; i < this.inputList.length; i++) { + if (this.inputList[i].name !== `ADD${i}`) return false; + } + return true; + }, }; Blockly.Blocks['dynamic_list_create'] = DYNAMIC_LIST_CREATE_MIXIN; diff --git a/plugins/block-dynamic-connection/src/dynamic_text_join.ts b/plugins/block-dynamic-connection/src/dynamic_text_join.ts index eece63b023..d5beec8901 100644 --- a/plugins/block-dynamic-connection/src/dynamic_text_join.ts +++ b/plugins/block-dynamic-connection/src/dynamic_text_join.ts @@ -108,12 +108,14 @@ const DYNAMIC_TEXT_JOIN_MIXIN = { * @returns The state of this block, ie the item count. */ saveExtraState: function (this: DynamicTextJoinBlock): {itemCount: number} { - // If we call finalizeConnections here without disabling events, we get into - // an event loop. - Blockly.Events.disable(); - this.finalizeConnections(); - if (this instanceof Blockly.BlockSvg) this.initSvg(); - Blockly.Events.enable(); + if (!this.isCorrectlyFormatted()) { + // If we call finalizeConnections here without disabling events, we get into + // an event loop. + Blockly.Events.disable(); + this.finalizeConnections(); + if (this instanceof Blockly.BlockSvg) this.initSvg(); + Blockly.Events.enable(); + } return { itemCount: this.itemCount, @@ -152,8 +154,11 @@ const DYNAMIC_TEXT_JOIN_MIXIN = { this: DynamicTextJoinBlock, connection: Blockly.Connection, ): number | null { - if (!connection.targetConnection) { - // this connection is available + if ( + !connection.targetConnection || + connection.targetBlock()?.isInsertionMarker() + ) { + // This connection is available. return null; } @@ -165,7 +170,7 @@ const DYNAMIC_TEXT_JOIN_MIXIN = { } if (connectionIndex == this.inputList.length - 1) { - // this connection is the last one and already has a block in it, so + // This connection is the last one and already has a block in it, so // we should add a new connection at the end. return this.inputList.length + 1; } @@ -179,7 +184,7 @@ const DYNAMIC_TEXT_JOIN_MIXIN = { return connectionIndex + 1; } - // Don't add new connection + // Don't add new connection. return null; }, @@ -278,6 +283,17 @@ const DYNAMIC_TEXT_JOIN_MIXIN = { Blockly.Msg['TEXT_JOIN_TITLE_CREATEWITH'], ); }, + + /** + * Returns true if all of the inputs on this block are in order. + * False otherwise. + */ + isCorrectlyFormatted(this: DynamicTextJoinBlock): boolean { + for (let i = 0; i < this.inputList.length; i++) { + if (this.inputList[i].name !== `ADD${i}`) return false; + } + return true; + }, }; Blockly.Blocks['dynamic_text_join'] = DYNAMIC_TEXT_JOIN_MIXIN; diff --git a/plugins/block-dynamic-connection/src/index.ts b/plugins/block-dynamic-connection/src/index.ts index 7c1c1733f6..bc581d2b4e 100644 --- a/plugins/block-dynamic-connection/src/index.ts +++ b/plugins/block-dynamic-connection/src/index.ts @@ -10,10 +10,12 @@ */ import * as Blockly from 'blockly/core'; -import './insertion_marker_manager_monkey_patch'; import './dynamic_if'; import './dynamic_text_join'; import './dynamic_list_create'; +import {decoratePreviewer} from './connection_previewer'; + +export {decoratePreviewer}; export const overrideOldBlockDefinitions = function (): void { Blockly.Blocks['lists_create_with'] = Blockly.Blocks['dynamic_list_create']; diff --git a/plugins/block-dynamic-connection/src/insertion_marker_manager_monkey_patch.ts b/plugins/block-dynamic-connection/src/insertion_marker_manager_monkey_patch.ts deleted file mode 100644 index d1a3eb9334..0000000000 --- a/plugins/block-dynamic-connection/src/insertion_marker_manager_monkey_patch.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * @fileoverview Overrides methods on Blockly.InsertionMarkerManager to - * allow blocks to hook in dynamic functionality when they have pending - * connections. - * @author anjali@code.org (Anjali Pal) - */ - -import * as Blockly from 'blockly/core'; - -// MonkeyPatchedInsertionMarkerManager overrides the update and dispose methods, -// and adds a new property called pendingBlocks. -interface MonkeyPatchedInsertionMarkerManager - extends Blockly.InsertionMarkerManager { - pendingBlocks: Set; -} - -// MonkeyPatchedInsertionMarkerManager relies on the dynamic blocks adding new -// methods called onPendingConnection and finalizeConnections. -interface DynamicBlock extends Blockly.Block { - onPendingConnection(connection: Blockly.Connection): void; - finalizeConnections(): void; -} - -// Override the update method, possibly adding the candidate to pendingBlocks. -// Hack: Private methods of InsertionMarkerManager are called using the array -// index syntax, bypassing access checking. The private methods are also missing -// type information in the d.ts files and are considered to return any here. -Blockly.InsertionMarkerManager.prototype.update = function ( - this: MonkeyPatchedInsertionMarkerManager, - dxy: Blockly.utils.Coordinate, - dragTarget: Blockly.IDragTarget | null, -): void { - const newCandidate = this['getCandidate'](dxy); - - this.wouldDeleteBlock = this['shouldDelete'](!!newCandidate, dragTarget); - - const shouldUpdate: boolean = - this.wouldDeleteBlock || this['shouldUpdatePreviews'](newCandidate, dxy); - - if (shouldUpdate) { - // Begin monkey patch - if (newCandidate?.closest?.sourceBlock_.onPendingConnection) { - newCandidate.closest.sourceBlock_.onPendingConnection( - newCandidate.closest, - ); - if (!this.pendingBlocks) { - this.pendingBlocks = new Set(); - } - this.pendingBlocks.add(newCandidate.closest.sourceBlock_); - } - // End monkey patch - // Don't fire events for insertion marker creation or movement. - Blockly.Events.disable(); - this['maybeHidePreview'](newCandidate); - this['maybeShowPreview'](newCandidate); - Blockly.Events.enable(); - } -}; - -const oldDispose = Blockly.InsertionMarkerManager.prototype.dispose; -Blockly.InsertionMarkerManager.prototype.dispose = function ( - this: MonkeyPatchedInsertionMarkerManager, -) { - if (this.pendingBlocks) { - this.pendingBlocks.forEach((block) => { - if ((block as DynamicBlock).finalizeConnections) { - (block as DynamicBlock).finalizeConnections(); - } - }); - } - oldDispose.call(this); -}; diff --git a/plugins/block-dynamic-connection/test/index.ts b/plugins/block-dynamic-connection/test/index.ts index 006c6aa61b..b057355055 100644 --- a/plugins/block-dynamic-connection/test/index.ts +++ b/plugins/block-dynamic-connection/test/index.ts @@ -85,6 +85,11 @@ document.addEventListener('DOMContentLoaded', function () { const defaultOptions: Blockly.BlocklyOptions = { toolbox, + plugins: { + connectionPreviewer: BlockDynamicConnection.decoratePreviewer( + Blockly.InsertionMarkerPreviewer, + ), + }, }; const rootElement = document.getElementById('root'); if (rootElement) {