diff --git a/src/context_menu_items.ts b/src/context_menu_items.ts index 847666c22c..dfa7a6249d 100644 --- a/src/context_menu_items.ts +++ b/src/context_menu_items.ts @@ -34,9 +34,7 @@ export function registerDeleteBlock() { if (!scope.block) { return } - Blockly.Events.setGroup(true) - scope.block.dispose(true, true) - Blockly.Events.setGroup(false) + deleteBlock(scope.block) }, scopeType: Blockly.ContextMenuRegistry.ScopeType.BLOCK, id: 'blockDelete', @@ -45,6 +43,34 @@ export function registerDeleteBlock() { Blockly.ContextMenuRegistry.registry.register(deleteOption) } +export function deleteBlock(block: Blockly.Block) { + if (block.workspace.isFlyout) return + if (!block.isDeletable() || block.isShadow()) return + + const priorGroup = Blockly.Events.getGroup() + const shouldStartGroup = !priorGroup + if (shouldStartGroup) { + Blockly.Events.setGroup(true) + } + try { + if (!block.outputConnection && !block.previousConnection?.isConnected() && block.nextConnection?.isConnected()) { + block.nextConnection.disconnect() + } + if (block.workspace instanceof Blockly.WorkspaceSvg) { + block.workspace.hideChaff() + } + if (block instanceof Blockly.BlockSvg) { + block.dispose(!block.outputConnection, true) + } else { + block.dispose(!block.outputConnection) + } + } finally { + if (shouldStartGroup) { + Blockly.Events.setGroup(false) + } + } +} + function getDeletableBlocksInStack(block: Blockly.Block): Blockly.Block[] { let descendants = block.getDescendants(false).filter(isDeletable) const nextBlock = block.getNextBlock() diff --git a/src/index.ts b/src/index.ts index d88a8f34d1..d5ffda53e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -189,6 +189,9 @@ if (!blockCommentMenuItem) { } Blockly.ContextMenuRegistry.registry.unregister('blockDelete') contextMenuItems.registerDeleteBlock() +Blockly.BlockSvg.prototype.checkAndDelete = function () { + contextMenuItems.deleteBlock(this) +} contextMenuItems.registerDuplicateBlock() contextMenuItems.registerCopyShortcut() contextMenuItems.registerCutShortcut() diff --git a/tests/unit/context_menu_items.test.ts b/tests/unit/context_menu_items.test.ts index 1961ff769d..c2147713af 100644 --- a/tests/unit/context_menu_items.test.ts +++ b/tests/unit/context_menu_items.test.ts @@ -4,7 +4,7 @@ */ import * as Blockly from 'blockly/core' import { afterAll, afterEach, assert, beforeAll, beforeEach, describe, expect, it } from 'vitest' -import { registerDeleteBlock } from '../../src/context_menu_items' +import { deleteBlock, registerDeleteBlock } from '../../src/context_menu_items' // Tests for the scratch-specific delete context menu override (registerDeleteBlock). // The copy/cut/paste override (registerDuplicateBlock, issue #3470) is tested @@ -171,4 +171,99 @@ describe('registerDeleteBlock', () => { delete Blockly.Blocks.test_output_block } }) + + it('callback deletes only a top stack block and preserves its next block', () => { + const first = workspace.newBlock('test_stack_block') + const second = workspace.newBlock('test_stack_block') + const nextConn = first.nextConnection + const prevConn = second.previousConnection + assert(nextConn, 'Expected next connection') + assert(prevConn, 'Expected previous connection') + nextConn.connect(prevConn) + + const item = Blockly.ContextMenuRegistry.registry.getItem('blockDelete') + assert(item, 'Expected blockDelete item to be registered') + const callback = item.callback as (scope: Blockly.ContextMenuRegistry.Scope) => void + callback({ block: asBlockSvg(first) }) + + expect(workspace.getAllBlocks(false)).toEqual([second]) + expect(second.getParent()).toBeNull() + }) + + it('callback deletes a lone stack block', () => { + const block = workspace.newBlock('test_stack_block') + + const item = Blockly.ContextMenuRegistry.registry.getItem('blockDelete') + assert(item, 'Expected blockDelete item to be registered') + ;(item.callback as (scope: Blockly.ContextMenuRegistry.Scope) => void)({ block: asBlockSvg(block) }) + + expect(workspace.getAllBlocks(false)).toEqual([]) + }) + + it('deleteBlock ignores non-deletable blocks', () => { + const block = workspace.newBlock('test_stack_block') + block.setDeletable(false) + + deleteBlock(block) + + expect(workspace.getAllBlocks(false)).toEqual([block]) + }) + + it('deleteBlock ignores shadow blocks', () => { + const block = workspace.newBlock('test_stack_block') + block.setShadow(true) + + deleteBlock(block) + + expect(workspace.getAllBlocks(false)).toEqual([block]) + }) + + it('callback reuses an active event group', () => { + const block = workspace.newBlock('test_stack_block') + const originalSetGroup = Reflect.get(Blockly.Events, 'setGroup') + const setGroupCalls: (boolean | string)[] = [] + + Blockly.Events.setGroup('outerGroup') + Blockly.Events.setGroup = (state: boolean | string) => { + setGroupCalls.push(state) + originalSetGroup(state) + } + try { + const item = Blockly.ContextMenuRegistry.registry.getItem('blockDelete') + assert(item, 'Expected blockDelete item to be registered') + ;(item.callback as (scope: Blockly.ContextMenuRegistry.Scope) => void)({ block: asBlockSvg(block) }) + + expect(setGroupCalls).not.toContain(true) + expect(Blockly.Events.getGroup()).toBe('outerGroup') + } finally { + Blockly.Events.setGroup = originalSetGroup + Blockly.Events.setGroup(false) + } + }) + + it('checkAndDelete override routes through deleteBlock and preserves next block', () => { + const first = workspace.newBlock('test_stack_block') + const second = workspace.newBlock('test_stack_block') + const nextConn = first.nextConnection + const prevConn = second.previousConnection + assert(nextConn, 'Expected next connection') + assert(prevConn, 'Expected previous connection') + nextConn.connect(prevConn) + + // Mirror the wiring in src/index.ts so we cover the keyboard-delete path. + // Tests use plain Workspace (not WorkspaceSvg), so blocks aren't BlockSvg + // instances — invoke the override via prototype to simulate the call site. + const originalCheckAndDelete = Reflect.get(Blockly.BlockSvg.prototype, 'checkAndDelete') + Blockly.BlockSvg.prototype.checkAndDelete = function () { + deleteBlock(this) + } + try { + Blockly.BlockSvg.prototype.checkAndDelete.call(first) + } finally { + Blockly.BlockSvg.prototype.checkAndDelete = originalCheckAndDelete + } + + expect(workspace.getAllBlocks(false)).toEqual([second]) + expect(second.getParent()).toBeNull() + }) })