From 1c167899903552566d84c4bc8112db8a462da79f Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Tue, 11 Aug 2026 14:27:52 +0800 Subject: [PATCH 1/6] :bug: fix: convert prototype and argument reporter to be regular blocks Signed-off-by: SimonShiki --- packages/block/src/blocks/extensions.ts | 10 + packages/block/src/blocks/procedures.ts | 162 ++++++++---- packages/block/src/connection_checker.ts | 13 + packages/block/src/dragger.ts | 36 ++- .../block/src/interfaces/i_block_template.ts | 23 ++ packages/block/src/interfaces/i_satellite.ts | 22 ++ .../block/src/interfaces/i_shadow_template.ts | 23 -- packages/block/src/procedures_category.ts | 2 +- packages/block/src/renderer/path_object.ts | 14 +- packages/block/src/renderer/render_info.ts | 15 +- packages/block/src/satellite.ts | 59 +++++ .../block/tests/blocks/procedures.test.ts | 238 ++++++++++++++++++ packages/vm/src/serialization/migration.js | 40 ++- packages/vm/src/serialization/sb3.js | 3 +- packages/vm/test/unit/serialization_sb3.js | 48 ++++ 15 files changed, 621 insertions(+), 87 deletions(-) create mode 100644 packages/block/src/interfaces/i_block_template.ts create mode 100644 packages/block/src/interfaces/i_satellite.ts delete mode 100644 packages/block/src/interfaces/i_shadow_template.ts create mode 100644 packages/block/src/satellite.ts create mode 100644 packages/block/tests/blocks/procedures.test.ts diff --git a/packages/block/src/blocks/extensions.ts b/packages/block/src/blocks/extensions.ts index 18b1052f2..2b45465c8 100644 --- a/packages/block/src/blocks/extensions.ts +++ b/packages/block/src/blocks/extensions.ts @@ -30,6 +30,8 @@ import * as Blockly from 'blockly/core'; import * as Constants from '../constants'; import type {ICheckboxInFlyout} from '../interfaces/i_checkbox_in_flyout'; import {IScratchExtensionBlock} from '../interfaces/i_scratch_extension'; +import {applySatelliteBehavior} from '../satellite'; +import type {ISatellite} from '../interfaces/i_satellite'; /** * Helper function that generates an extension based on a category name. @@ -120,6 +122,13 @@ const SCRATCH_EXTENSION = function(this: Blockly.Block) { (this as Blockly.Block & IScratchExtensionBlock).isScratchExtension = true; }; +/** + * Extension for blocks that are owned and moved through their parent block. + */ +const SATELLITE_BLOCK = function(this: Blockly.Block) { + applySatelliteBehavior(this as Blockly.BlockSvg & ISatellite); +}; + /** * Extension to make a checkbox before the block when in a flyout. */ @@ -155,6 +164,7 @@ const registerAll = function() { // Extension blocks have slightly different block rendering. Blockly.Extensions.register('scratch_extension', SCRATCH_EXTENSION); + Blockly.Extensions.register('satellite_block', SATELLITE_BLOCK); // Register extension for monitor blocks. Blockly.Extensions.register('monitor_block', MONITOR_BLOCK); diff --git a/packages/block/src/blocks/procedures.ts b/packages/block/src/blocks/procedures.ts index 6cdbe922d..acd91772c 100644 --- a/packages/block/src/blocks/procedures.ts +++ b/packages/block/src/blocks/procedures.ts @@ -34,7 +34,8 @@ import { } from '../procedures_category'; import {ProcedureModel} from '../procedure_model'; import {ParameterModel} from '../parameter_model'; -import type {IShadowTemplate} from '../interfaces/i_shadow_template'; +import type {IBlockTemplate} from '../interfaces/i_block_template'; +import type {ISatellite} from '../interfaces/i_satellite'; import type {IDynamicDeletable} from '../interfaces/i_dynamic_deletable'; import {FuncChange} from '../events/func_change'; @@ -45,6 +46,11 @@ interface ConnectionMap { } | null } +interface SerializedProcedureExtraState extends ProcedureExtraState { + /** True when the serialized block also contains its child inputs. */ + hasSerializedInputs?: boolean; +} + export interface ProcedureBlock extends Blockly.BlockSvg { model: ProcedureModel; @@ -53,7 +59,7 @@ export interface ProcedureBlock extends Blockly.BlockSvg { getProcedureModel: () => ProcedureModel; removeAllInputs_: () => void; disconnectOldBlocks_: () => ConnectionMap; - deleteShadows_: (connectionMap: ConnectionMap) => void; + deleteObsoleteBlocks_: (connectionMap: ConnectionMap) => void; createAllInputs_: (connectionMap: ConnectionMap) => void; updateDisplay_: () => void; @@ -91,13 +97,14 @@ export interface ProcedureCallBlock extends ProcedureBlock { buildShadowState_: (type: string) => Blockly.serialization.blocks.State; } -export interface ProcedurePrototypeBlock extends ProcedureBlock { +export interface ProcedurePrototypeBlock extends ProcedureBlock, ISatellite { type: 'procedures_prototype'; saveExtraState: () => ProcedureExtraState, loadExtraState: (state: ProcedureExtraState) => void, + skipArgumentReporters_: boolean; - createArgumentReporter_: (argumentType: string, displayName: string) => Blockly.BlockSvg; + createArgumentReporter_: (argumentType: string, displayName: string) => ProcedureArgumentReporterBlock; updateArgumentReporterNames_: (prevArgIds: string[], prevDisplayNames: string[]) => void; } @@ -126,9 +133,7 @@ export interface ProcedureArgumentEditorBlock extends Blockly.BlockSvg { removeFieldCallback: (field: Blockly.Field) => void; } -export interface ProcedureArgumentReporterBlock extends Blockly.BlockSvg, IShadowTemplate { - shadowTemplate: boolean; -} +export interface ProcedureArgumentReporterBlock extends Blockly.BlockSvg, IBlockTemplate {} // Helper functions to check type of procedure blocks. @@ -244,6 +249,22 @@ function definitionMutationToDom( return container; } +/** + * Determine whether an XML mutation belongs to a block whose input children + * will be restored separately by Blockly's XML loader. + * @param mutation The procedure mutation element. + * @returns True when the owning block contains a direct value element. + */ +function hasXmlInputChildren(mutation: Element): boolean { + const owner = mutation.parentElement; + if (!owner) return false; + + for (const value of Array.from(owner.getElementsByTagName('value'))) { + if (value.parentElement === owner) return true; + } + return false; +} + /** * Parse XML to restore the (non-editable) name and arguments of a * procedures_prototype block or a procedures_declaration block. @@ -253,15 +274,26 @@ function definitionDomToMutation( this: ProcedurePrototypeBlock | ProcedureDeclarationBlock, xmlElement: Element ) { - this.loadExtraState({ - proccode: xmlElement.getAttribute('proccode')!, - warp: JSON.parse(xmlElement.getAttribute('warp')!), - return: JSON.parse(xmlElement.getAttribute('return')!), - global: JSON.parse(xmlElement.getAttribute('global')!), - argumentids: JSON.parse(xmlElement.getAttribute('argumentids')!), - argumentnames: JSON.parse(xmlElement.getAttribute('argumentnames')!), - argumentdefaults: JSON.parse(xmlElement.getAttribute('argumentdefaults')!) - }); + const hasSerializedArgumentReporters = this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE && + hasXmlInputChildren(xmlElement); + if (hasSerializedArgumentReporters && this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { + this.skipArgumentReporters_ = true; + } + try { + this.loadExtraState({ + proccode: xmlElement.getAttribute('proccode')!, + warp: JSON.parse(xmlElement.getAttribute('warp')!), + return: JSON.parse(xmlElement.getAttribute('return')!), + global: JSON.parse(xmlElement.getAttribute('global')!), + argumentids: JSON.parse(xmlElement.getAttribute('argumentids')!), + argumentnames: JSON.parse(xmlElement.getAttribute('argumentnames')!), + argumentdefaults: JSON.parse(xmlElement.getAttribute('argumentdefaults')!) + }); + } finally { + if (hasSerializedArgumentReporters && this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { + this.skipArgumentReporters_ = false; + } + } } /** @@ -332,17 +364,17 @@ function callerLoadExtraState( /** * Create state to represent the (non-editable) name and arguments of a * procedures_prototype block or a procedures_declaration block. - * @param generateShadows Whether to include the generateshadows - * flag in the generated state. False if not provided. + * @param doFullSerialization Whether Blockly is serializing external state + * fully. Workspace saves pass false and include child input blocks. * @returns Extra state. */ function definitionSaveExtraState( this: ProcedurePrototypeBlock | ProcedureDeclarationBlock, - generateShadows?: boolean + doFullSerialization?: boolean ): ProcedureExtraState { const extraState = this.model.saveExtraState(); - if (generateShadows) { - extraState.generateshadows = true; + if (this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE && doFullSerialization === false) { + (extraState as SerializedProcedureExtraState).hasSerializedInputs = true; } return extraState; } @@ -354,8 +386,12 @@ function definitionSaveExtraState( */ function definitionLoadExtraState( this: ProcedurePrototypeBlock | ProcedureDeclarationBlock, - state: ProcedureExtraState + state: SerializedProcedureExtraState ) { + const hasSerializedInputs = this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE && + state.hasSerializedInputs === true; + delete state.hasSerializedInputs; + if (!this.model) { const procedureMap = this.workspace.getProcedureMap(); if (procedureMap.has(state.proccode)) { @@ -374,7 +410,19 @@ function definitionLoadExtraState( state.argumentdefaults = parseStringOrObject(state.argumentdefaults); this.model.loadExtraState(state); - this.updateDisplay_(); + if ( + (hasSerializedInputs || ('skipArgumentReporters_' in this && this.skipArgumentReporters_)) && + this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE + ) { + this.skipArgumentReporters_ = true; + try { + this.updateDisplay_(); + } finally { + this.skipArgumentReporters_ = false; + } + } else { + this.updateDisplay_(); + } if ('updateArgumentReporterNames_' in this) { this.updateArgumentReporterNames_( extraState.argumentids, @@ -414,7 +462,7 @@ function updateDisplay(this: ProcedureBlock) { this.removeAllInputs_(); this.updateShape_(); this.createAllInputs_(connectionMap); - this.deleteShadows_(connectionMap); + this.deleteObsoleteBlocks_(connectionMap); } /** @@ -498,11 +546,11 @@ function createAllInputs(this: ProcedureBlock, connectionMap: ConnectionMap) { } /** - * Delete all shadow blocks in the given map. + * Delete all obsolete blocks in the given map. * @param connectionMap An object mapping argument IDs to the blocks that * were connected to those IDs at the beginning of the mutation. */ -function deleteShadows(this: ProcedureBlock, connectionMap: ConnectionMap) { +function deleteObsoleteBlocks(this: ProcedureBlock, connectionMap: ConnectionMap) { // Get rid of all of the old shadow blocks if they aren't connected. if (connectionMap) { for (const id in connectionMap) { @@ -512,7 +560,9 @@ function deleteShadows(this: ProcedureBlock, connectionMap: ConnectionMap) { const saveInfo = connectionMap[id]; if (saveInfo) { const block = saveInfo['block']; - if (block && block.isShadow()) { + const isPrototypeReporter = this.type === 'procedures_prototype' && + block && isProcedureArgumentReporterBlock(block); + if (block && (block.isShadow() || isPrototypeReporter)) { block.dispose(true); connectionMap[id] = null; // At this point we know which shadow DOMs are about to be orphaned in @@ -616,8 +666,9 @@ function createArgumentReporter( Blockly.Events.disable(); let newBlock; try { - newBlock = this.workspace.newBlock(blockType) as Blockly.BlockSvg; - newBlock.setShadow(true); + newBlock = this.workspace.newBlock(blockType) as ProcedureArgumentReporterBlock; + newBlock.setDeletable(false); + newBlock.blockTemplate = true; newBlock.setFieldValue(displayName, 'VALUE'); if (!this.isInsertionMarker()) { newBlock.initSvg(); @@ -687,6 +738,10 @@ function populateArgumentOnPrototype( id: string, input: Blockly.Input ) { + if (this.skipArgumentReporters_) { + return; + } + let oldBlock = null; if (connectionMap && (id in connectionMap)) { const saveInfo = connectionMap[id]!; @@ -697,17 +752,20 @@ function populateArgumentOnPrototype( const displayName = this.model.getParameter(index).getName(); // Decide which block to attach. - let argumentReporter; + let argumentReporter: ProcedureArgumentReporterBlock; if (connectionMap && oldBlock && oldTypeMatches) { // Update the text if needed. The old argument reporter is the same type, // and on the same input, but the argument's display name may have changed. - argumentReporter = oldBlock; + argumentReporter = oldBlock as ProcedureArgumentReporterBlock; argumentReporter.setFieldValue(displayName, 'VALUE'); connectionMap[input.name] = null; } else { argumentReporter = this.createArgumentReporter_(type, displayName); } + argumentReporter.blockTemplate = true; + argumentReporter.setDeletable(false); + // Attach the block. input.connection!.connect(argumentReporter.outputConnection!); } @@ -1031,8 +1089,8 @@ function updateArgumentReporterNames( // Create a list of argument reporters that are descendants of the definition stack (see above comment) const allBlocks = definitionBlock.getDescendants(false) as Blockly.BlockSvg[]; for (const block of allBlocks) { - if (isProcedureArgumentReporterBlock(block) && !block.isShadow()) { - // Exclude arg reporters in the prototype block, which are shadows. + if (isProcedureArgumentReporterBlock(block) && block.getParent()?.id !== this.id) { + // Exclude argument reporters owned by the prototype itself. argReporters.push(block); } } @@ -1205,7 +1263,7 @@ Blockly.Blocks['procedures_call'] = { getProcedureModel: getProcedureModel, removeAllInputs_: removeAllInputs, disconnectOldBlocks_: disconnectOldBlocks, - deleteShadows_: deleteShadows, + deleteObsoleteBlocks_: deleteObsoleteBlocks, createAllInputs_: createAllInputs, updateDisplay_: updateDisplay, @@ -1243,17 +1301,18 @@ Blockly.Blocks['procedures_call'] = { * define block. */ Blockly.Blocks['procedures_prototype'] = { - init: function() { + init: function(this: ProcedurePrototypeBlock) { this.jsonInit({ - extensions: ['colours_more', 'shape_statement'] + extensions: ['colours_more', 'shape_statement', 'satellite_block'] }); + this.skipArgumentReporters_ = false; }, // Shared. getProcCode: getProcCode, getProcedureModel: getProcedureModel, removeAllInputs_: removeAllInputs, disconnectOldBlocks_: disconnectOldBlocks, - deleteShadows_: deleteShadows, + deleteObsoleteBlocks_: deleteObsoleteBlocks, createAllInputs_: createAllInputs, updateDisplay_: updateDisplay, @@ -1272,7 +1331,6 @@ Blockly.Blocks['procedures_prototype'] = { this.setShape_(isReturn ? Constants.OUTPUT_SHAPE_ROUND : Constants.OUTPUT_SHAPE_NORMAL, true); } }, - // Only exists on procedures_prototype. createArgumentReporter_: createArgumentReporter, updateArgumentReporterNames_: updateArgumentReporterNames @@ -1294,7 +1352,7 @@ Blockly.Blocks['procedures_declaration'] = { getProcedureModel: getProcedureModel, removeAllInputs_: removeAllInputs, disconnectOldBlocks_: disconnectOldBlocks, - deleteShadows_: deleteShadows, + deleteObsoleteBlocks_: deleteObsoleteBlocks, createAllInputs_: createAllInputs, updateDisplay_: updateDisplay, @@ -1393,7 +1451,7 @@ Blockly.Blocks['procedures_discard'] = { }; Blockly.Blocks['argument_reporter_boolean'] = { - init: function() { + init: function(this: ProcedureArgumentReporterBlock) { this.jsonInit({ message0: '%1', args0: [{ @@ -1403,12 +1461,21 @@ Blockly.Blocks['argument_reporter_boolean'] = { }], extensions: ['colours_argument', 'output_boolean'] }); - this.shadowTemplate = true; + this.blockTemplate = true; + const originalShowContextMenu = this.showContextMenu.bind(this); + this.showContextMenu = function(e: Event) { + const parent = this.getParent(); + if (parent?.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { + parent.showContextMenu(e); + } else { + originalShowContextMenu(e); + } + }; } } as ProcedureArgumentReporterBlock; Blockly.Blocks['argument_reporter_string_number'] = { - init: function() { + init: function(this: ProcedureArgumentReporterBlock) { this.jsonInit({ message0: '%1', args0: [{ @@ -1418,7 +1485,16 @@ Blockly.Blocks['argument_reporter_string_number'] = { }], extensions: ['colours_argument', 'output_number', 'output_string'] }); - this.shadowTemplate = true; + this.blockTemplate = true; + const originalShowContextMenu = this.showContextMenu.bind(this); + this.showContextMenu = function(e: Event) { + const parent = this.getParent(); + if (parent?.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { + parent.showContextMenu(e); + } else { + originalShowContextMenu(e); + } + }; } } as ProcedureArgumentReporterBlock; diff --git a/packages/block/src/connection_checker.ts b/packages/block/src/connection_checker.ts index f56f69cc4..d1c7f3b0d 100644 --- a/packages/block/src/connection_checker.ts +++ b/packages/block/src/connection_checker.ts @@ -6,6 +6,7 @@ import * as Blockly from 'blockly/core'; import * as Constants from './constants'; +import {isBlockTemplate} from './interfaces/i_block_template'; /** * Class for connection type checking logic with custom rules. @@ -33,6 +34,18 @@ export class ConnectionChecker extends Blockly.ConnectionChecker { ) { return false; } + + // Procedure prototype inputs are managed by the procedure mutation and + // must not be replaced by user drag-and-drop. + if (b.getSourceBlock().type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { + return false; + } + + // Template reporters remain permanently attached to their prototype. + const targetBlock = b.targetBlock(); + if (isBlockTemplate(targetBlock) && targetBlock.blockTemplate) { + return false; + } } return canConnect; diff --git a/packages/block/src/dragger.ts b/packages/block/src/dragger.ts index 9b17f8d32..d2a6ca9ed 100644 --- a/packages/block/src/dragger.ts +++ b/packages/block/src/dragger.ts @@ -5,7 +5,9 @@ */ import * as Blockly from 'blockly/core'; -import {isShadowTemplate} from './interfaces/i_shadow_template'; +import * as Constants from './constants'; +import {isBlockTemplate} from './interfaces/i_block_template'; +import {isSatellite} from './interfaces/i_satellite'; import {isDynamicDeletable} from './interfaces/i_dynamic_deletable'; import {BlockDragOutside} from './events/block_drag_outside'; import {BlockDragEnd} from './events/block_drag_end'; @@ -26,8 +28,8 @@ export class Dragger extends Blockly.dragging.Dragger { protected dragWorkspace!: Blockly.WorkspaceSvg; /** - * Handles any drag startup. Shadow template blocks should be duplicated - * before dragging. + * Handles any drag startup. Template blocks should be duplicated before + * dragging when they are attached to their owning prototype. * @param e The pointer event. * @returns The draggable object. */ @@ -48,15 +50,23 @@ export class Dragger extends Blockly.dragging.Dragger { this.originatedFromFlyout = true; } - // Duplicate the shadow template block and drag the new block. + // Duplicate a template reporter and drag the new regular block. if ( - this.draggable.isShadow() && isShadowTemplate(this.draggable) && this.draggable.shadowTemplate + isBlockTemplate(this.draggable) && this.draggable.blockTemplate ) { - if (!Blockly.Events.getGroup()) { - Blockly.Events.setGroup(true); + const parent = this.draggable.getParent(); + if (parent?.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { + if (!Blockly.Events.getGroup()) { + Blockly.Events.setGroup(true); + } + this.draggable = this.duplicateBlock(this.draggable); + Blockly.getFocusManager().focusNode(this.draggable as Blockly.BlockSvg); + } else { + // A template reporter that escaped its prototype is an ordinary + // user block and must be removable and draggable normally. + this.draggable.blockTemplate = false; + this.draggable.setDeletable(true); } - this.draggable = this.duplicateBlock(this.draggable); - Blockly.getFocusManager().focusNode(this.draggable as Blockly.BlockSvg); } } @@ -150,7 +160,8 @@ export class Dragger extends Blockly.dragging.Dragger { * @returns The root block for the drag event. */ protected getDragRoot(block: Blockly.BlockSvg) { - return block.isShadow() ? block.getParent() as Blockly.BlockSvg : block; + return block.isShadow() || (isSatellite(block) && block.satellite) ? + block.getParent() as Blockly.BlockSvg : block; } /** @@ -182,6 +193,11 @@ export class Dragger extends Blockly.dragging.Dragger { this.draggable.workspace.setResizesEnabled(false); const newBlock = Blockly.serialization.blocks.append(json, this.draggable.workspace) as Blockly.BlockSvg; + if (isBlockTemplate(newBlock)) { + newBlock.blockTemplate = false; + } + newBlock.setDeletable(true); + newBlock.moveTo(originalBlock.getRelativeToSurfaceXY()); Blockly.Events.enable(); diff --git a/packages/block/src/interfaces/i_block_template.ts b/packages/block/src/interfaces/i_block_template.ts new file mode 100644 index 000000000..6b7cda850 --- /dev/null +++ b/packages/block/src/interfaces/i_block_template.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface IBlockTemplate { + /** + * True if the block should be duplicated before dragging while it is used as + * a template. + */ + blockTemplate: boolean; +} + +/** + * Returns whether the given object is an IBlockTemplate. + * @param obj The object to decide. + * @returns True if obj is an IBlockTemplate. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isBlockTemplate(obj: any): obj is IBlockTemplate { + return obj && typeof obj.blockTemplate === 'boolean'; +} diff --git a/packages/block/src/interfaces/i_satellite.ts b/packages/block/src/interfaces/i_satellite.ts new file mode 100644 index 000000000..f1cd9fe05 --- /dev/null +++ b/packages/block/src/interfaces/i_satellite.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ISatellite { + /** + * True if the block is a visual part owned by its parent block. + */ + satellite: boolean; +} + +/** + * Returns whether the given object is an ISatellite. + * @param obj The object to decide. + * @returns True if obj is an ISatellite. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isSatellite(obj: any): obj is ISatellite { + return obj && typeof obj.satellite === 'boolean'; +} diff --git a/packages/block/src/interfaces/i_shadow_template.ts b/packages/block/src/interfaces/i_shadow_template.ts deleted file mode 100644 index b6b35fdb1..000000000 --- a/packages/block/src/interfaces/i_shadow_template.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright 2025 Clip Team - * SPDX-License-Identifier: Apache-2.0 - */ - -export interface IShadowTemplate { - /** - * True if the block should be duplicated before dragging if the block is a - * shadow block. - */ - shadowTemplate: boolean; -} - -/** - * Returns whether the given object is an IShadowTemplate or not. - * @param obj The object to decide. - * @returns True if obj is IShadowTemplate - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isShadowTemplate(obj: any): obj is IShadowTemplate { - return obj && typeof obj.shadowTemplate === 'boolean'; -} diff --git a/packages/block/src/procedures_category.ts b/packages/block/src/procedures_category.ts index 298430bc9..983b87e9e 100644 --- a/packages/block/src/procedures_category.ts +++ b/packages/block/src/procedures_category.ts @@ -227,7 +227,7 @@ function createProcedureCallbackFactory( type: Constants.PROCEDURES_DEFINITION_BLOCK_TYPE, inputs: { custom_block: { - shadow: { + block: { type: Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE, extraState: state } diff --git a/packages/block/src/renderer/path_object.ts b/packages/block/src/renderer/path_object.ts index 5d7870fe2..46e1f6b0f 100644 --- a/packages/block/src/renderer/path_object.ts +++ b/packages/block/src/renderer/path_object.ts @@ -5,7 +5,8 @@ */ import * as Blockly from 'blockly/core'; -import {isShadowTemplate} from '../interfaces/i_shadow_template'; +import * as Constants from '../constants'; +import {isBlockTemplate} from '../interfaces/i_block_template'; /** * An object that handles creating and setting each of the SVG elements @@ -20,8 +21,15 @@ export class PathObject extends Blockly.zelos.PathObject { override applyColour(block: Blockly.BlockSvg): void { super.applyColour(block); - // Shadow templates should render in normal colour. - if (isShadowTemplate(block) && block.shadowTemplate) { + // The prototype is regular for interaction purposes but still renders + // like a shadow block. + if (block.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { + this.svgPath.setAttribute('fill', this.style.colourSecondary); + } + + // Template reporters need the normal colour to contrast with the + // secondary-coloured prototype. + if (isBlockTemplate(block) && block.blockTemplate) { this.svgPath.setAttribute('fill', this.style.colourPrimary); } } diff --git a/packages/block/src/renderer/render_info.ts b/packages/block/src/renderer/render_info.ts index 6cef876c3..b5bfa3010 100644 --- a/packages/block/src/renderer/render_info.ts +++ b/packages/block/src/renderer/render_info.ts @@ -9,7 +9,7 @@ import * as Constants from '../constants'; import {InlineStatementInput} from './measurables/inline_statement_input'; import {BowlerHat} from './measurables/bowler_hat'; import {isInvisibleIcon} from '../interfaces/i_invisible_icon'; -import {isShadowTemplate} from '../interfaces/i_shadow_template'; +import {isBlockTemplate} from '../interfaces/i_block_template'; import {isScratchExtensionBlock} from '../interfaces/i_scratch_extension'; /** @@ -103,10 +103,14 @@ export class RenderInfo extends Blockly.zelos.RenderInfo { * @param activeRow The row that is currently being populated. */ override addInput_(input: Blockly.Input, activeRow: Blockly.blockRendering.Row): void { - // Render shadow statement inputs as inline. + // Render procedure definition inputs as inline. The prototype is now a + // regular block, so it no longer provides shadow DOM for this input. if ( input instanceof Blockly.inputs.StatementInput && - input.connection && input.getShadowDom() !== null + input.connection && ( + input.getShadowDom() !== null || + input.getSourceBlock().type === Constants.PROCEDURES_DEFINITION_BLOCK_TYPE + ) ) { activeRow.elements.push(new InlineStatementInput(this.constants_, input)); return; @@ -119,7 +123,8 @@ export class RenderInfo extends Blockly.zelos.RenderInfo { if (input instanceof Blockly.inputs.DummyInput || input instanceof Blockly.inputs.EndRowInput) { const sourceBlock = input.getSourceBlock(); if ( - (isShadowTemplate(sourceBlock) && sourceBlock.shadowTemplate) || + (isBlockTemplate(sourceBlock) && sourceBlock.blockTemplate) || + sourceBlock.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE || (sourceBlock.isShadow() && sourceBlock.previousConnection) ) { // Dummy and end-row inputs have no visual representation, but the @@ -279,6 +284,6 @@ export class RenderInfo extends Blockly.zelos.RenderInfo { * @returns True if parent block should apply tight-nesting. */ protected shouldTightNesting(connectedBlock: Blockly.BlockSvg) { - return !connectedBlock.isShadow() || (isShadowTemplate(connectedBlock) && connectedBlock.shadowTemplate); + return !connectedBlock.isShadow() || (isBlockTemplate(connectedBlock) && connectedBlock.blockTemplate); } } diff --git a/packages/block/src/satellite.ts b/packages/block/src/satellite.ts new file mode 100644 index 000000000..3d9b346e1 --- /dev/null +++ b/packages/block/src/satellite.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; +import type {ISatellite} from './interfaces/i_satellite'; + +/** + * Drag strategy for a satellite block whose parent owns its movement. + */ +class SatelliteDragStrategy implements Blockly.IDragStrategy { + constructor(private readonly block: Blockly.BlockSvg) {} + + isMovable() { + return this.block.getParent()?.isMovable() ?? false; + } + + startDrag(e?: PointerEvent | KeyboardEvent): Blockly.IDraggable { + return this.block.getParent()?.startDrag(e) ?? this.block; + } + + drag(newLoc: Blockly.utils.Coordinate, e?: PointerEvent | KeyboardEvent) { + this.block.getParent()?.drag(newLoc, e); + } + + endDrag( + e: PointerEvent | KeyboardEvent | undefined, + disposition: Blockly.DragDisposition + ) { + this.block.getParent()?.endDrag(e, disposition); + } + + revertDrag() { + this.block.getParent()?.revertDrag(); + } +} + +/** + * Applies the common interaction behavior for a satellite block. + * @param block The satellite block. + */ +export function applySatelliteBehavior(block: Blockly.BlockSvg & ISatellite): void { + block.satellite = true; + block.setDeletable(false); + block.isDuplicatable = () => false; + block.setDragStrategy(new SatelliteDragStrategy(block)); + + const originalShowContextMenu = block.showContextMenu.bind(block); + block.showContextMenu = function(e: Event) { + const parent = this.getParent(); + if (parent) { + parent.showContextMenu(e); + } else { + originalShowContextMenu(e); + } + }; +} diff --git a/packages/block/tests/blocks/procedures.test.ts b/packages/block/tests/blocks/procedures.test.ts new file mode 100644 index 000000000..ba244a39a --- /dev/null +++ b/packages/block/tests/blocks/procedures.test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2026 Clip Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, test} from '@jest/globals'; +import * as Blockly from 'blockly/core'; +import type {ProcedureCallerExtraState, ProcedureExtraState} from '../../src/serialization/procedures'; +import {Dragger} from '../../src/dragger'; +import {setupPlayground} from '../helpers/playground'; + +/** + * Create procedure prototype state for a single argument. + * @param type Argument type. + * @param id Argument ID. + * @param name Argument display name. + * @returns Procedure prototype state. + */ +function procedureState(type: 'b' | 'n' | 's', id: string, name = 'parameter'): ProcedureExtraState { + return { + proccode: `procedure %${type}`, + argumentids: [id], + argumentdefaults: [''], + argumentnames: [name], + warp: false, + return: false, + global: false + }; +} + +/** + * Create procedure caller state for a single argument. + * @param type Argument type. + * @param id Argument ID. + * @returns Procedure caller state. + */ +function callerState(type: 'b' | 'n' | 's', id: string): ProcedureCallerExtraState { + return { + proccode: `procedure %${type}`, + argumentids: [id], + warp: false, + return: false, + global: false, + generateshadows: false + }; +} + +describe('Blocks: Procedures', () => { + const context = setupPlayground(); + + /** + * Create a prototype block without recording setup events. + * @param state Procedure prototype state. + * @returns The created prototype block. + */ + function createPrototype(state: ProcedureExtraState) { + Blockly.Events.disable(); + try { + const block = context.workspace.newBlock('procedures_prototype') as Blockly.BlockSvg & { + loadExtraState: (state: ProcedureExtraState) => void; + }; + block.initSvg(); + block.loadExtraState(state); + return block; + } finally { + Blockly.Events.enable(); + } + } + + /** + * Create a caller block without recording setup events. + * @param state Procedure caller state. + * @returns The created caller block. + */ + function createCaller(state: ProcedureCallerExtraState) { + Blockly.Events.disable(); + try { + const block = context.workspace.newBlock('procedures_call') as Blockly.BlockSvg & { + loadExtraState: (state: ProcedureCallerExtraState) => void; + }; + block.initSvg(); + block.loadExtraState(state); + return block; + } finally { + Blockly.Events.enable(); + } + } + + test('Prototype and argument reporter are regular blocks', () => { + const prototype = createPrototype(procedureState('s', 'ARG')); + const reporter = prototype.getInputTargetBlock('ARG')!; + + expect(prototype.isShadow()).toBe(false); + expect(prototype.isDeletable()).toBe(false); + expect(prototype.isDuplicatable()).toBe(false); + expect((prototype as Blockly.BlockSvg & {satellite: boolean}).satellite).toBe(true); + expect(reporter.isShadow()).toBe(false); + expect((reporter as Blockly.BlockSvg & {blockTemplate: boolean}).blockTemplate).toBe(true); + expect(reporter.isDeletable()).toBe(false); + }); + + test('Prototype input cannot be replaced by drag-and-drop', () => { + const prototype = createPrototype(procedureState('s', 'ARG')); + const replacement = context.workspace.newBlock('text') as Blockly.BlockSvg; + replacement.initSvg(); + + const inputConnection = prototype.getInput('ARG')!.connection! as Blockly.RenderedConnection; + const outputConnection = replacement.outputConnection! as Blockly.RenderedConnection; + expect(context.workspace.connectionChecker.doDragChecks(outputConnection, inputConnection, 0)).toBe(false); + }); + + test('Dragging a template reporter creates a regular clone', () => { + const prototype = createPrototype(procedureState('s', 'ARG')); + const reporter = prototype.getInputTargetBlock('ARG')! as Blockly.BlockSvg & { + blockTemplate: boolean; + }; + const originalId = reporter.id; + const dragger = new Dragger(reporter); + const clone = dragger.onDragStart(new PointerEvent('pointerdown', { + bubbles: true, + clientX: 0, + clientY: 0, + pointerType: 'mouse' + })) as Blockly.BlockSvg & {blockTemplate: boolean}; + + expect(clone.id).not.toBe(originalId); + expect(clone.isShadow()).toBe(false); + expect(clone.blockTemplate).toBe(false); + expect(clone.isDeletable()).toBe(true); + expect(reporter.getParent()).toBe(prototype); + + clone.dispose(true, false); + }); + + test('Serialized regular prototype does not create duplicate reporters', () => { + const state: Blockly.serialization.blocks.State = { + type: 'procedures_definition', + inputs: { + custom_block: { + block: { + type: 'procedures_prototype', + extraState: procedureState('s', 'ARG'), + inputs: { + ARG: { + block: { + type: 'argument_reporter_string_number', + fields: {VALUE: 'parameter'} + } + } + } + } + } + } + }; + + const definition = Blockly.serialization.blocks.append(state, context.workspace) as Blockly.BlockSvg; + const prototype = definition.getInputTargetBlock('custom_block')!; + + const reporters = prototype.getDescendants(false).filter( + (block) => block.type === 'argument_reporter_string_number' + ); + expect(reporters).toHaveLength(1); + expect(reporters[0].isShadow()).toBe(false); + }); + + test('Removed prototype reporters are disposed', () => { + const prototype = createPrototype(procedureState('s', 'OLD')) as Blockly.BlockSvg & { + loadExtraState: (state: ProcedureExtraState) => void; + }; + const oldReporter = prototype.getInputTargetBlock('OLD')!; + + prototype.loadExtraState(procedureState('n', 'NEW')); + + expect(context.workspace.getBlockById(oldReporter.id)).toBeNull(); + expect(prototype.getInputTargetBlock('NEW')).not.toBeNull(); + }); + + test('Regular argument reporters outside prototypes are not disposed', () => { + const caller = createCaller(callerState('s', 'ARG')) as Blockly.BlockSvg & { + loadExtraState: (state: ProcedureCallerExtraState) => void; + }; + const reporter = context.workspace.newBlock('argument_reporter_string_number') as Blockly.BlockSvg; + reporter.initSvg(); + reporter.outputConnection!.connect(caller.getInput('ARG')!.connection!); + + caller.loadExtraState({ + proccode: 'procedure', + argumentids: [], + warp: false, + return: false, + global: false, + generateshadows: false + }); + + expect(context.workspace.getBlockById(reporter.id)).not.toBeNull(); + }); + + test('Workspace round-trip does not orphan duplicate template reporters', () => { + context.workspace.clear(); + context.workspace.getProcedureMap().clear(); + + const state: Blockly.serialization.blocks.State = { + type: 'procedures_definition', + inputs: { + custom_block: { + block: { + type: 'procedures_prototype', + extraState: procedureState('s', 'ARG') + } + } + } + }; + Blockly.serialization.blocks.append(state, context.workspace); + + const saved = Blockly.serialization.workspaces.save(context.workspace); + context.workspace.clear(); + context.workspace.getProcedureMap().clear(); + Blockly.serialization.workspaces.load(saved, context.workspace); + + const reporters = context.workspace.getAllBlocks(false).filter( + (block) => block.type === 'argument_reporter_string_number' + ); + expect(reporters).toHaveLength(1); + expect(reporters[0].getParent()?.type).toBe('procedures_prototype'); + + const xml = Blockly.Xml.workspaceToDom(context.workspace); + context.workspace.clear(); + context.workspace.getProcedureMap().clear(); + Blockly.Xml.domToWorkspace(xml, context.workspace); + + const xmlReporters = context.workspace.getAllBlocks(false).filter( + (block) => block.type === 'argument_reporter_string_number' + ); + expect(xmlReporters).toHaveLength(1); + expect(xmlReporters[0].getParent()?.type).toBe('procedures_prototype'); + }); +}); diff --git a/packages/vm/src/serialization/migration.js b/packages/vm/src/serialization/migration.js index bc2057be2..2a4607135 100644 --- a/packages/vm/src/serialization/migration.js +++ b/packages/vm/src/serialization/migration.js @@ -22,6 +22,12 @@ const migrationMap = { } }; +const templateBlockOpcodes = new Set([ + 'procedures_prototype', + 'argument_reporter_string_number', + 'argument_reporter_boolean' +]); + const mergeDeep = (target, ...sources) => { if (!sources.length) return target; const source = sources.shift(); @@ -125,9 +131,41 @@ const migrateMutation = (block, backward) => { return mutation; }; +/** + * Migrate procedure prototype blocks and their argument reporters from the + * legacy shadow representation to regular blocks. + * + * The input relationship must be migrated as well as the child block flag. + * Otherwise block === shadow would cause the next serialization to recreate + * the legacy shadow representation. + * @param {Record} blocks Hydrated VM blocks. Mutated in place. + * @returns {Record} The migrated blocks. + */ +const migrateTemplateBlocks = blocks => { + for (const blockId in blocks) { + if (!Object.prototype.hasOwnProperty.call(blocks, blockId)) continue; + const block = blocks[blockId]; + if (!block || !templateBlockOpcodes.has(block.opcode) || !block.shadow) continue; + + block.shadow = false; + + if (!block.parent || !blocks[block.parent]) continue; + const parent = blocks[block.parent]; + for (const inputName in parent.inputs) { + if (!Object.prototype.hasOwnProperty.call(parent.inputs, inputName)) continue; + const input = parent.inputs[inputName]; + if (input.block === blockId && input.shadow === blockId) { + input.shadow = null; + } + } + } + return blocks; +}; + export { migrationMap, mergeDeep, - migrateMutation + migrateMutation, + migrateTemplateBlocks }; diff --git a/packages/vm/src/serialization/sb3.js b/packages/vm/src/serialization/sb3.js index 9bdea4eba..08aa111b9 100644 --- a/packages/vm/src/serialization/sb3.js +++ b/packages/vm/src/serialization/sb3.js @@ -17,7 +17,7 @@ import uid from '../util/uid'; import MathUtil from '../util/math-util'; import StringUtil from '../util/string-util'; import VariableUtil from '../util/variable-util'; -import {migrationMap, mergeDeep, migrateMutation} from './migration.js'; +import {migrationMap, mergeDeep, migrateMutation, migrateTemplateBlocks} from './migration.js'; import {loadCostume} from '../import/load-costume'; import {loadSound} from '../import/load-sound'; import {deserializeCostume, deserializeSound} from './deserialize-assets'; @@ -993,6 +993,7 @@ const parseScratchObject = function (object, runtime, extensions, zip, assets) { } if (Object.prototype.hasOwnProperty.call(object, 'blocks')) { deserializeBlocks(object.blocks); + migrateTemplateBlocks(object.blocks); // Take a second pass to create objects and add extensions for (const blockId in object.blocks) { if (!Object.prototype.hasOwnProperty.call(object.blocks, blockId)) continue; diff --git a/packages/vm/test/unit/serialization_sb3.js b/packages/vm/test/unit/serialization_sb3.js index 12f1a14f2..ebf6b0c5a 100644 --- a/packages/vm/test/unit/serialization_sb3.js +++ b/packages/vm/test/unit/serialization_sb3.js @@ -3,6 +3,7 @@ import path from 'path'; import VirtualMachine from '../../src/index'; import Runtime from '../../src/engine/runtime'; import * as sb3 from '../../src/serialization/sb3.js'; +import {migrateTemplateBlocks} from '../../src/serialization/migration.js'; import {readFileToBuffer} from '../fixtures/readProjectFile.js'; const exampleProjectPath = path.resolve(__dirname, '../fixtures/clone-cleanup.sb2'); const commentsSB2ProjectPath = path.resolve(__dirname, '../fixtures/comments.sb2'); @@ -35,6 +36,53 @@ test('deserialize', t => { }); }); +test('migrate legacy procedure template blocks to regular blocks', t => { + const blocks = { + definition: { + id: 'definition', + opcode: 'procedures_definition', + parent: null, + shadow: false, + inputs: { + custom_block: { + block: 'prototype', + shadow: 'prototype' + } + } + }, + prototype: { + id: 'prototype', + opcode: 'procedures_prototype', + parent: 'definition', + shadow: true, + inputs: { + ARG: { + block: 'reporter', + shadow: 'reporter' + } + } + }, + reporter: { + id: 'reporter', + opcode: 'argument_reporter_string_number', + parent: 'prototype', + shadow: true, + inputs: {} + } + }; + + migrateTemplateBlocks(blocks); + + t.equal(blocks.prototype.shadow, false); + t.equal(blocks.reporter.shadow, false); + t.equal(blocks.definition.inputs.custom_block.shadow, null); + t.equal(blocks.prototype.inputs.ARG.shadow, null); + + migrateTemplateBlocks(blocks); + t.equal(blocks.prototype.inputs.ARG.block, 'reporter'); + t.end(); +}); + test('serialize sb2 project with comments as sb3', t => { const vm = new VirtualMachine(); From 9247acbf1b02b9dd1b31c8533698443e3655ba84 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Tue, 11 Aug 2026 18:55:49 +0800 Subject: [PATCH 2/6] :bug: fix(vm): mark hasSerializedInputs in vm-side Signed-off-by: SimonShiki --- packages/vm/src/engine/blocks.ts | 10 +++++- packages/vm/test/unit/engine_blocks.js | 44 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/vm/src/engine/blocks.ts b/packages/vm/src/engine/blocks.ts index aa79ad8b2..b9d8b32ff 100644 --- a/packages/vm/src/engine/blocks.ts +++ b/packages/vm/src/engine/blocks.ts @@ -1494,7 +1494,15 @@ class Blocks { // Add any mutation. if (block.mutation) { - state.extraState = block.mutation; + if (block.opcode === 'procedures_prototype' && + Object.values(block.inputs).some(input => input.block !== null)) { + state.extraState = { + ...block.mutation, + hasSerializedInputs: true + }; + } else { + state.extraState = block.mutation; + } } const danglingInputs = this._getDanglingInputs(block); diff --git a/packages/vm/test/unit/engine_blocks.js b/packages/vm/test/unit/engine_blocks.js index fd7f396b9..78856cd26 100644 --- a/packages/vm/test/unit/engine_blocks.js +++ b/packages/vm/test/unit/engine_blocks.js @@ -31,6 +31,50 @@ test('spec', t => { t.end(); }); +test('toState marks procedure prototype child inputs', t => { + const blocks = new Blocks(new Runtime()); + blocks.createBlock({ + id: 'definition', + opcode: 'procedures_definition', + next: null, + parent: null, + fields: {}, + inputs: { + custom_block: {name: 'custom_block', block: 'prototype', shadow: null} + }, + topLevel: true, + shadow: false + }); + blocks.createBlock({ + id: 'prototype', + opcode: 'procedures_prototype', + next: null, + parent: 'definition', + fields: {}, + inputs: { + ARG: {name: 'ARG', block: 'reporter', shadow: null} + }, + mutation: {proccode: 'procedure %s'}, + topLevel: false, + shadow: false + }); + blocks.createBlock({ + id: 'reporter', + opcode: 'argument_reporter_string_number', + next: null, + parent: 'prototype', + fields: {VALUE: {name: 'VALUE', value: 'parameter'}}, + inputs: {}, + topLevel: false, + shadow: false + }); + + const state = blocks.toState(); + const prototypeState = state[0].inputs.custom_block.block; + t.equal(prototypeState.extraState.hasSerializedInputs, true); + t.end(); +}); + // Getter tests test('getBlock', t => { const b = new Blocks(new Runtime()); From 7fd9b08d7652338a9fb90a73a8515eb1ea2a9b89 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 12 Aug 2026 11:23:17 +0800 Subject: [PATCH 3/6] :bug: fix(block): draw inline statement input by our own Signed-off-by: SimonShiki --- packages/block/src/renderer/drawer.ts | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/block/src/renderer/drawer.ts b/packages/block/src/renderer/drawer.ts index ee408982a..ac32fa12b 100644 --- a/packages/block/src/renderer/drawer.ts +++ b/packages/block/src/renderer/drawer.ts @@ -6,12 +6,47 @@ import * as Blockly from 'blockly/core'; import {BowlerHat} from './measurables/bowler_hat'; +import {InlineStatementInput} from './measurables/inline_statement_input'; import type {RenderInfo} from './render_info'; /** * An object that draws a block based on the given rendering information. */ export class Drawer extends Blockly.zelos.Drawer { + override drawInlineInput_(input: Blockly.blockRendering.InlineInput): void { + if (input instanceof InlineStatementInput) { + this.drawInlineStatementInput_(input); + return; + } + + super.drawInlineInput_(input); + } + + /** + * Draw an inline statement input without using Zelos' dynamic value shape + * path. Statement connections retain their notch shape for highlighting. + * @param input The inline statement input to draw. + */ + protected drawInlineStatementInput_(input: InlineStatementInput): void { + this.positionInlineInputConnection_(input); + + if (input.connectedBlock || this.info_.isInsertionMarker) { + return; + } + + const yPos = input.centerline - input.height / 2; + const connectionRight = input.xPos + input.connectionWidth; + const width = Math.max(0, input.width - input.connectionWidth * 2); + const path = + Blockly.utils.svgPaths.moveTo(connectionRight, yPos) + + Blockly.utils.svgPaths.lineOnAxis('h', width) + + Blockly.utils.svgPaths.lineOnAxis('v', input.height) + + Blockly.utils.svgPaths.lineOnAxis('h', -width) + + 'z'; + + (this.block_.pathObject as Blockly.zelos.PathObject).setOutlinePath(input.input.name, path); + } + protected override drawInternals_(): void { super.drawInternals_(); From b695ad225a8692e9459ade0cd212432c48a8eecf Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 12 Aug 2026 13:13:26 +0800 Subject: [PATCH 4/6] :bug: fix(block): never set blockTemplate on init Signed-off-by: SimonShiki --- packages/block/src/blocks/procedures.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/block/src/blocks/procedures.ts b/packages/block/src/blocks/procedures.ts index acd91772c..fa7eb7edd 100644 --- a/packages/block/src/blocks/procedures.ts +++ b/packages/block/src/blocks/procedures.ts @@ -1461,7 +1461,6 @@ Blockly.Blocks['argument_reporter_boolean'] = { }], extensions: ['colours_argument', 'output_boolean'] }); - this.blockTemplate = true; const originalShowContextMenu = this.showContextMenu.bind(this); this.showContextMenu = function(e: Event) { const parent = this.getParent(); @@ -1485,7 +1484,6 @@ Blockly.Blocks['argument_reporter_string_number'] = { }], extensions: ['colours_argument', 'output_number', 'output_string'] }); - this.blockTemplate = true; const originalShowContextMenu = this.showContextMenu.bind(this); this.showContextMenu = function(e: Event) { const parent = this.getParent(); From 8f4c48f8c78f280e8fc4e55b68c8b450eedc2981 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 12 Aug 2026 13:57:12 +0800 Subject: [PATCH 5/6] :wrench: chore(block): decide whether behaves like a template by block itself Signed-off-by: SimonShiki --- packages/block/src/blocks/extensions.ts | 58 +++++++++++++++++- packages/block/src/blocks/procedures.ts | 19 ++---- .../block/src/interfaces/i_block_template.ts | 7 ++- packages/block/src/satellite.ts | 59 ------------------- 4 files changed, 67 insertions(+), 76 deletions(-) delete mode 100644 packages/block/src/satellite.ts diff --git a/packages/block/src/blocks/extensions.ts b/packages/block/src/blocks/extensions.ts index 2b45465c8..f84ebc519 100644 --- a/packages/block/src/blocks/extensions.ts +++ b/packages/block/src/blocks/extensions.ts @@ -30,7 +30,7 @@ import * as Blockly from 'blockly/core'; import * as Constants from '../constants'; import type {ICheckboxInFlyout} from '../interfaces/i_checkbox_in_flyout'; import {IScratchExtensionBlock} from '../interfaces/i_scratch_extension'; -import {applySatelliteBehavior} from '../satellite'; +import type {IBlockTemplate} from '../interfaces/i_block_template'; import type {ISatellite} from '../interfaces/i_satellite'; /** @@ -122,11 +122,60 @@ const SCRATCH_EXTENSION = function(this: Blockly.Block) { (this as Blockly.Block & IScratchExtensionBlock).isScratchExtension = true; }; +const BLOCK_TEMPLATE = function(this: Blockly.Block & IBlockTemplate) { + if (this.templateOf && this.getSurroundParent()?.type !== this.templateOf) return; + this.setDeletable(false); + this.blockTemplate = true; +}; + +/** + * Drag strategy for a satellite block whose parent owns its movement. + */ +class SatelliteDragStrategy implements Blockly.IDragStrategy { + constructor(private readonly block: Blockly.BlockSvg) { } + + isMovable() { + return !!this.block.getParent()?.isMovable(); + } + + startDrag(e?: PointerEvent | KeyboardEvent): Blockly.IDraggable { + return this.block.getParent()?.startDrag(e) ?? this.block; + } + + drag(newLoc: Blockly.utils.Coordinate, e?: PointerEvent | KeyboardEvent) { + this.block.getParent()?.drag(newLoc, e); + } + + endDrag( + e: PointerEvent | KeyboardEvent | undefined, + disposition: Blockly.DragDisposition + ) { + this.block.getParent()?.endDrag(e, disposition); + } + + revertDrag() { + this.block.getParent()?.revertDrag(); + } +} + /** * Extension for blocks that are owned and moved through their parent block. */ -const SATELLITE_BLOCK = function(this: Blockly.Block) { - applySatelliteBehavior(this as Blockly.BlockSvg & ISatellite); +const SATELLITE_BLOCK = function(this: Blockly.BlockSvg & ISatellite) { + this.satellite = true; + this.setDeletable(false); + this.isDuplicatable = () => false; + this.setDragStrategy(new SatelliteDragStrategy(this)); + + const originalShowContextMenu = this.showContextMenu.bind(this); + this.showContextMenu = function(e: Event) { + const parent = this.getParent(); + if (parent) { + parent.showContextMenu(e); + } else { + originalShowContextMenu(e); + } + }; }; /** @@ -164,6 +213,9 @@ const registerAll = function() { // Extension blocks have slightly different block rendering. Blockly.Extensions.register('scratch_extension', SCRATCH_EXTENSION); + + // Extensions for advanced usage + Blockly.Extensions.register('block_template', BLOCK_TEMPLATE); Blockly.Extensions.register('satellite_block', SATELLITE_BLOCK); // Register extension for monitor blocks. diff --git a/packages/block/src/blocks/procedures.ts b/packages/block/src/blocks/procedures.ts index fa7eb7edd..0d573cc1b 100644 --- a/packages/block/src/blocks/procedures.ts +++ b/packages/block/src/blocks/procedures.ts @@ -388,8 +388,7 @@ function definitionLoadExtraState( this: ProcedurePrototypeBlock | ProcedureDeclarationBlock, state: SerializedProcedureExtraState ) { - const hasSerializedInputs = this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE && - state.hasSerializedInputs === true; + const hasSerializedInputs = state.hasSerializedInputs; delete state.hasSerializedInputs; if (!this.model) { @@ -410,10 +409,7 @@ function definitionLoadExtraState( state.argumentdefaults = parseStringOrObject(state.argumentdefaults); this.model.loadExtraState(state); - if ( - (hasSerializedInputs || ('skipArgumentReporters_' in this && this.skipArgumentReporters_)) && - this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE - ) { + if (hasSerializedInputs && this.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { this.skipArgumentReporters_ = true; try { this.updateDisplay_(); @@ -667,8 +663,6 @@ function createArgumentReporter( let newBlock; try { newBlock = this.workspace.newBlock(blockType) as ProcedureArgumentReporterBlock; - newBlock.setDeletable(false); - newBlock.blockTemplate = true; newBlock.setFieldValue(displayName, 'VALUE'); if (!this.isInsertionMarker()) { newBlock.initSvg(); @@ -763,9 +757,6 @@ function populateArgumentOnPrototype( argumentReporter = this.createArgumentReporter_(type, displayName); } - argumentReporter.blockTemplate = true; - argumentReporter.setDeletable(false); - // Attach the block. input.connection!.connect(argumentReporter.outputConnection!); } @@ -1459,8 +1450,9 @@ Blockly.Blocks['argument_reporter_boolean'] = { name: 'VALUE', text: '' }], - extensions: ['colours_argument', 'output_boolean'] + extensions: ['colours_argument', 'output_boolean', 'block_template'] }); + this.templateOf = Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE; const originalShowContextMenu = this.showContextMenu.bind(this); this.showContextMenu = function(e: Event) { const parent = this.getParent(); @@ -1482,8 +1474,9 @@ Blockly.Blocks['argument_reporter_string_number'] = { name: 'VALUE', text: '' }], - extensions: ['colours_argument', 'output_number', 'output_string'] + extensions: ['colours_argument', 'output_number', 'output_string', 'block_template'] }); + this.templateOf = Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE; const originalShowContextMenu = this.showContextMenu.bind(this); this.showContextMenu = function(e: Event) { const parent = this.getParent(); diff --git a/packages/block/src/interfaces/i_block_template.ts b/packages/block/src/interfaces/i_block_template.ts index 6b7cda850..164249a00 100644 --- a/packages/block/src/interfaces/i_block_template.ts +++ b/packages/block/src/interfaces/i_block_template.ts @@ -10,6 +10,11 @@ export interface IBlockTemplate { * a template. */ blockTemplate: boolean; + /** + * behaves like a template block if it's templateOf's child. + * It will get applied on block init. + */ + templateOf: string; } /** @@ -19,5 +24,5 @@ export interface IBlockTemplate { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isBlockTemplate(obj: any): obj is IBlockTemplate { - return obj && typeof obj.blockTemplate === 'boolean'; + return obj && typeof obj.blockTemplate === 'boolean' && typeof obj.templateOf === 'string'; } diff --git a/packages/block/src/satellite.ts b/packages/block/src/satellite.ts deleted file mode 100644 index 3d9b346e1..000000000 --- a/packages/block/src/satellite.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @license - * Copyright 2026 Clip Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as Blockly from 'blockly/core'; -import type {ISatellite} from './interfaces/i_satellite'; - -/** - * Drag strategy for a satellite block whose parent owns its movement. - */ -class SatelliteDragStrategy implements Blockly.IDragStrategy { - constructor(private readonly block: Blockly.BlockSvg) {} - - isMovable() { - return this.block.getParent()?.isMovable() ?? false; - } - - startDrag(e?: PointerEvent | KeyboardEvent): Blockly.IDraggable { - return this.block.getParent()?.startDrag(e) ?? this.block; - } - - drag(newLoc: Blockly.utils.Coordinate, e?: PointerEvent | KeyboardEvent) { - this.block.getParent()?.drag(newLoc, e); - } - - endDrag( - e: PointerEvent | KeyboardEvent | undefined, - disposition: Blockly.DragDisposition - ) { - this.block.getParent()?.endDrag(e, disposition); - } - - revertDrag() { - this.block.getParent()?.revertDrag(); - } -} - -/** - * Applies the common interaction behavior for a satellite block. - * @param block The satellite block. - */ -export function applySatelliteBehavior(block: Blockly.BlockSvg & ISatellite): void { - block.satellite = true; - block.setDeletable(false); - block.isDuplicatable = () => false; - block.setDragStrategy(new SatelliteDragStrategy(block)); - - const originalShowContextMenu = block.showContextMenu.bind(block); - block.showContextMenu = function(e: Event) { - const parent = this.getParent(); - if (parent) { - parent.showContextMenu(e); - } else { - originalShowContextMenu(e); - } - }; -} From e67757550beef16653a793dc59fd82f29368672d Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 13 Aug 2026 11:38:08 +0800 Subject: [PATCH 6/6] :wrench: chore(block): refactor block template Signed-off-by: SimonShiki --- packages/block/src/blocks/extensions.ts | 2 +- packages/block/src/connection_checker.ts | 10 ++- packages/block/src/dragger.ts | 64 +++++++--------- .../block/src/interfaces/i_block_template.ts | 25 ++++-- packages/block/src/renderer/path_object.ts | 8 -- packages/block/src/renderer/render_info.ts | 6 +- .../block/tests/blocks/procedures.test.ts | 76 +++++++++++++++++-- 7 files changed, 124 insertions(+), 67 deletions(-) diff --git a/packages/block/src/blocks/extensions.ts b/packages/block/src/blocks/extensions.ts index f84ebc519..624acc02f 100644 --- a/packages/block/src/blocks/extensions.ts +++ b/packages/block/src/blocks/extensions.ts @@ -125,7 +125,7 @@ const SCRATCH_EXTENSION = function(this: Blockly.Block) { const BLOCK_TEMPLATE = function(this: Blockly.Block & IBlockTemplate) { if (this.templateOf && this.getSurroundParent()?.type !== this.templateOf) return; this.setDeletable(false); - this.blockTemplate = true; + this.isDuplicatable = () => this.getParent()?.type !== this.templateOf; }; /** diff --git a/packages/block/src/connection_checker.ts b/packages/block/src/connection_checker.ts index d1c7f3b0d..3227b0d84 100644 --- a/packages/block/src/connection_checker.ts +++ b/packages/block/src/connection_checker.ts @@ -6,7 +6,7 @@ import * as Blockly from 'blockly/core'; import * as Constants from './constants'; -import {isBlockTemplate} from './interfaces/i_block_template'; +import {isActiveTemplateBlock} from './interfaces/i_block_template'; /** * Class for connection type checking logic with custom rules. @@ -41,9 +41,13 @@ export class ConnectionChecker extends Blockly.ConnectionChecker { return false; } - // Template reporters remain permanently attached to their prototype. + // Active template blocks remain permanently attached to their + // container. They must not be replaced nor connected with other blocks. const targetBlock = b.targetBlock(); - if (isBlockTemplate(targetBlock) && targetBlock.blockTemplate) { + if ( + isActiveTemplateBlock(b.getSourceBlock()) || + (targetBlock && isActiveTemplateBlock(targetBlock)) + ) { return false; } } diff --git a/packages/block/src/dragger.ts b/packages/block/src/dragger.ts index d2a6ca9ed..5eb0433ba 100644 --- a/packages/block/src/dragger.ts +++ b/packages/block/src/dragger.ts @@ -5,8 +5,7 @@ */ import * as Blockly from 'blockly/core'; -import * as Constants from './constants'; -import {isBlockTemplate} from './interfaces/i_block_template'; +import {isActiveTemplateBlock, isBlockTemplate} from './interfaces/i_block_template'; import {isSatellite} from './interfaces/i_satellite'; import {isDynamicDeletable} from './interfaces/i_dynamic_deletable'; import {BlockDragOutside} from './events/block_drag_outside'; @@ -28,45 +27,41 @@ export class Dragger extends Blockly.dragging.Dragger { protected dragWorkspace!: Blockly.WorkspaceSvg; /** - * Handles any drag startup. Template blocks should be duplicated before - * dragging when they are attached to their owning prototype. + * Handles any drag startup. Active template blocks should be duplicated + * before dragging when they are attached to their owning block. * @param e The pointer event. * @returns The draggable object. */ override onDragStart(e: PointerEvent | KeyboardEvent): Blockly.IDraggable { this.dragWorkspace = this.draggable.workspace; - if (e instanceof PointerEvent && this.draggable instanceof Blockly.BlockSvg) { - const workspace = this.dragWorkspace; - // Make elements can drag outside of workspace bounds. - workspace.addClass(Dragger.BOUNDLESS_CLASS); - const absoluteMetrics = workspace.getMetricsManager().getAbsoluteMetrics(); - const viewMetrics = workspace.getMetricsManager().getViewMetrics(); - if ( - workspace.RTL ? - e.clientX > workspace.getParentSvg().getBoundingClientRect().left + - viewMetrics.width : - e.clientX < absoluteMetrics.left - ) { - this.originatedFromFlyout = true; + if (this.draggable instanceof Blockly.BlockSvg) { + if (e instanceof PointerEvent) { + const workspace = this.dragWorkspace; + // Make elements can drag outside of workspace bounds. + workspace.addClass(Dragger.BOUNDLESS_CLASS); + const absoluteMetrics = workspace.getMetricsManager().getAbsoluteMetrics(); + const viewMetrics = workspace.getMetricsManager().getViewMetrics(); + if ( + workspace.RTL ? + e.clientX > workspace.getParentSvg().getBoundingClientRect().left + + viewMetrics.width : + e.clientX < absoluteMetrics.left + ) { + this.originatedFromFlyout = true; + } } - // Duplicate a template reporter and drag the new regular block. - if ( - isBlockTemplate(this.draggable) && this.draggable.blockTemplate - ) { - const parent = this.draggable.getParent(); - if (parent?.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { - if (!Blockly.Events.getGroup()) { - Blockly.Events.setGroup(true); - } - this.draggable = this.duplicateBlock(this.draggable); - Blockly.getFocusManager().focusNode(this.draggable as Blockly.BlockSvg); - } else { - // A template reporter that escaped its prototype is an ordinary - // user block and must be removable and draggable normally. - this.draggable.blockTemplate = false; - this.draggable.setDeletable(true); + // Duplicate an active template block and drag the new regular block. + if (isActiveTemplateBlock(this.draggable)) { + if (!Blockly.Events.getGroup()) { + Blockly.Events.setGroup(true); } + this.draggable = this.duplicateBlock(this.draggable); + Blockly.getFocusManager().focusNode(this.draggable); + } else if (isBlockTemplate(this.draggable)) { + // A template reporter that escaped its container is an ordinary + // user block and must be removable and draggable normally. + this.draggable.setDeletable(true); } } @@ -193,9 +188,6 @@ export class Dragger extends Blockly.dragging.Dragger { this.draggable.workspace.setResizesEnabled(false); const newBlock = Blockly.serialization.blocks.append(json, this.draggable.workspace) as Blockly.BlockSvg; - if (isBlockTemplate(newBlock)) { - newBlock.blockTemplate = false; - } newBlock.setDeletable(true); newBlock.moveTo(originalBlock.getRelativeToSurfaceXY()); diff --git a/packages/block/src/interfaces/i_block_template.ts b/packages/block/src/interfaces/i_block_template.ts index 164249a00..008196aad 100644 --- a/packages/block/src/interfaces/i_block_template.ts +++ b/packages/block/src/interfaces/i_block_template.ts @@ -4,15 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type * as Blockly from 'blockly/core'; + export interface IBlockTemplate { /** - * True if the block should be duplicated before dragging while it is used as - * a template. - */ - blockTemplate: boolean; - /** - * behaves like a template block if it's templateOf's child. - * It will get applied on block init. + * The type of the owning block. The block behaves like a template block + * while it is a direct child of that type of block. */ templateOf: string; } @@ -24,5 +21,17 @@ export interface IBlockTemplate { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isBlockTemplate(obj: any): obj is IBlockTemplate { - return obj && typeof obj.blockTemplate === 'boolean' && typeof obj.templateOf === 'string'; + return obj && typeof obj.templateOf === 'string'; +} + +/** + * Returns whether the given block is currently acting as a template, i.e. it + * is a template block that is still attached to its owning block. + * @param block The block to decide. + * @returns True if the block is an active template. + */ +export function isActiveTemplateBlock( + block: Blockly.Block & Partial +): block is Blockly.Block & IBlockTemplate { + return isBlockTemplate(block) && block.getParent()?.type === block.templateOf; } diff --git a/packages/block/src/renderer/path_object.ts b/packages/block/src/renderer/path_object.ts index 46e1f6b0f..4c4e5afb0 100644 --- a/packages/block/src/renderer/path_object.ts +++ b/packages/block/src/renderer/path_object.ts @@ -6,8 +6,6 @@ import * as Blockly from 'blockly/core'; import * as Constants from '../constants'; -import {isBlockTemplate} from '../interfaces/i_block_template'; - /** * An object that handles creating and setting each of the SVG elements * used by the renderer. @@ -26,11 +24,5 @@ export class PathObject extends Blockly.zelos.PathObject { if (block.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE) { this.svgPath.setAttribute('fill', this.style.colourSecondary); } - - // Template reporters need the normal colour to contrast with the - // secondary-coloured prototype. - if (isBlockTemplate(block) && block.blockTemplate) { - this.svgPath.setAttribute('fill', this.style.colourPrimary); - } } } diff --git a/packages/block/src/renderer/render_info.ts b/packages/block/src/renderer/render_info.ts index b5bfa3010..8a51758ff 100644 --- a/packages/block/src/renderer/render_info.ts +++ b/packages/block/src/renderer/render_info.ts @@ -9,7 +9,7 @@ import * as Constants from '../constants'; import {InlineStatementInput} from './measurables/inline_statement_input'; import {BowlerHat} from './measurables/bowler_hat'; import {isInvisibleIcon} from '../interfaces/i_invisible_icon'; -import {isBlockTemplate} from '../interfaces/i_block_template'; +import {isActiveTemplateBlock} from '../interfaces/i_block_template'; import {isScratchExtensionBlock} from '../interfaces/i_scratch_extension'; /** @@ -123,7 +123,7 @@ export class RenderInfo extends Blockly.zelos.RenderInfo { if (input instanceof Blockly.inputs.DummyInput || input instanceof Blockly.inputs.EndRowInput) { const sourceBlock = input.getSourceBlock(); if ( - (isBlockTemplate(sourceBlock) && sourceBlock.blockTemplate) || + isActiveTemplateBlock(sourceBlock) || sourceBlock.type === Constants.PROCEDURES_PROTOTYPE_BLOCK_TYPE || (sourceBlock.isShadow() && sourceBlock.previousConnection) ) { @@ -284,6 +284,6 @@ export class RenderInfo extends Blockly.zelos.RenderInfo { * @returns True if parent block should apply tight-nesting. */ protected shouldTightNesting(connectedBlock: Blockly.BlockSvg) { - return !connectedBlock.isShadow() || (isBlockTemplate(connectedBlock) && connectedBlock.blockTemplate); + return !connectedBlock.isShadow() || isActiveTemplateBlock(connectedBlock); } } diff --git a/packages/block/tests/blocks/procedures.test.ts b/packages/block/tests/blocks/procedures.test.ts index ba244a39a..d1b6948af 100644 --- a/packages/block/tests/blocks/procedures.test.ts +++ b/packages/block/tests/blocks/procedures.test.ts @@ -8,6 +8,7 @@ import {describe, expect, test} from '@jest/globals'; import * as Blockly from 'blockly/core'; import type {ProcedureCallerExtraState, ProcedureExtraState} from '../../src/serialization/procedures'; import {Dragger} from '../../src/dragger'; +import {isActiveTemplateBlock} from '../../src/interfaces/i_block_template'; import {setupPlayground} from '../helpers/playground'; /** @@ -89,15 +90,16 @@ describe('Blocks: Procedures', () => { test('Prototype and argument reporter are regular blocks', () => { const prototype = createPrototype(procedureState('s', 'ARG')); - const reporter = prototype.getInputTargetBlock('ARG')!; + const reporter = prototype.getInputTargetBlock('ARG')! as Blockly.BlockSvg; expect(prototype.isShadow()).toBe(false); expect(prototype.isDeletable()).toBe(false); expect(prototype.isDuplicatable()).toBe(false); expect((prototype as Blockly.BlockSvg & {satellite: boolean}).satellite).toBe(true); expect(reporter.isShadow()).toBe(false); - expect((reporter as Blockly.BlockSvg & {blockTemplate: boolean}).blockTemplate).toBe(true); + expect(isActiveTemplateBlock(reporter)).toBe(true); expect(reporter.isDeletable()).toBe(false); + expect(reporter.isDuplicatable()).toBe(false); }); test('Prototype input cannot be replaced by drag-and-drop', () => { @@ -110,11 +112,30 @@ describe('Blocks: Procedures', () => { expect(context.workspace.connectionChecker.doDragChecks(outputConnection, inputConnection, 0)).toBe(false); }); + test('Active template reporter output cannot connect to other blocks', async () => { + const prototype = createPrototype(procedureState('s', 'ARG')); + const reporter = prototype.getInputTargetBlock('ARG')! as Blockly.BlockSvg; + const holder = context.workspace.newBlock('operator_add') as Blockly.BlockSvg; + holder.initSvg(); + + await Blockly.renderManagement.finishQueuedRenders(); + + const outputConnection = reporter.outputConnection! as Blockly.RenderedConnection; + const inputConnection = holder.getInput('NUM1')!.connection! as Blockly.RenderedConnection; + expect(context.workspace.connectionChecker.doDragChecks(inputConnection, outputConnection, Infinity)).toBe(false); + + // A clone dragged out of the template is a regular block and may connect. + const json = Blockly.serialization.blocks.save(reporter)!; + const clone = Blockly.serialization.blocks.append(json, context.workspace) as Blockly.BlockSvg; + const cloneOutput = clone.outputConnection! as Blockly.RenderedConnection; + expect(context.workspace.connectionChecker.doDragChecks(cloneOutput, inputConnection, Infinity)).toBe(true); + + clone.dispose(true); + }); + test('Dragging a template reporter creates a regular clone', () => { const prototype = createPrototype(procedureState('s', 'ARG')); - const reporter = prototype.getInputTargetBlock('ARG')! as Blockly.BlockSvg & { - blockTemplate: boolean; - }; + const reporter = prototype.getInputTargetBlock('ARG')! as Blockly.BlockSvg; const originalId = reporter.id; const dragger = new Dragger(reporter); const clone = dragger.onDragStart(new PointerEvent('pointerdown', { @@ -122,15 +143,54 @@ describe('Blocks: Procedures', () => { clientX: 0, clientY: 0, pointerType: 'mouse' - })) as Blockly.BlockSvg & {blockTemplate: boolean}; + })) as Blockly.BlockSvg; expect(clone.id).not.toBe(originalId); expect(clone.isShadow()).toBe(false); - expect(clone.blockTemplate).toBe(false); + expect(isActiveTemplateBlock(clone)).toBe(false); expect(clone.isDeletable()).toBe(true); + expect(clone.isDuplicatable()).toBe(true); expect(reporter.getParent()).toBe(prototype); - clone.dispose(true, false); + clone.dispose(true); + }); + + test('Keyboard-moving a template reporter creates a regular clone', () => { + const prototype = createPrototype(procedureState('s', 'ARG')); + const reporter = prototype.getInputTargetBlock('ARG')! as Blockly.BlockSvg; + const originalId = reporter.id; + const dragger = new Dragger(reporter); + const clone = dragger.onDragStart(new KeyboardEvent('keydown', { + bubbles: true, + key: 'ArrowRight' + })) as Blockly.BlockSvg; + + expect(clone.id).not.toBe(originalId); + expect(isActiveTemplateBlock(clone)).toBe(false); + expect(reporter.getParent()).toBe(prototype); + + clone.dispose(true); + }); + + test('Dragging an escaped template reporter moves the original block', () => { + const prototype = createPrototype(procedureState('s', 'ARG')); + const reporter = prototype.getInputTargetBlock('ARG')! as Blockly.BlockSvg; + reporter.unplug(false); + reporter.setDeletable(false); + + const dragger = new Dragger(reporter); + const draggable = dragger.onDragStart(new PointerEvent('pointerdown', { + bubbles: true, + clientX: 0, + clientY: 0, + pointerType: 'mouse' + })) as Blockly.BlockSvg; + + expect(draggable.id).toBe(reporter.id); + expect(isActiveTemplateBlock(reporter)).toBe(false); + expect(reporter.isDeletable()).toBe(true); + + reporter.dispose(true); }); test('Serialized regular prototype does not create duplicate reporters', () => {