From 31a602a6ef192b4dab3b1dfb0c02c2235171a244 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 26 Nov 2025 09:26:26 +0800 Subject: [PATCH 1/7] :sparkles: feat(block): unknown blocks Signed-off-by: SimonShiki --- packages/block/src/blocks/common.ts | 70 +++++++++++++++++++++++++ packages/block/src/blocks/extensions.ts | 2 +- packages/block/src/colours.ts | 11 ++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/block/src/blocks/common.ts b/packages/block/src/blocks/common.ts index 2599dad4c..fe211a0c0 100644 --- a/packages/block/src/blocks/common.ts +++ b/packages/block/src/blocks/common.ts @@ -208,3 +208,73 @@ Blockly.Blocks['text'] = { }); } }; + +/* Special Blocks */ + +interface UnknownBlock extends Blockly.BlockSvg { + blockInfo: Record; + placeholderText: string; + + updateDisplay_: () => void; + removeAllInputs: () => void; + updateShape_: () => void; + setPlaceholderText_: (text: string) => void; +} + +interface UnknownBlockExtraState { + blockInfo: Record; + placeholderText: string; +} + +/** + * Placeholder block for non-existing blocks in clipcc-block. + * It stores the unknown block info and placeholder text in extra state. its shape based on its connection status. + * So that it won't break the renderer and users can identify the missing blocks. + * WARNING: this block should only exists in blockly side, VM should never see this block. + */ +Blockly.Blocks['unknown'] = { + init: function(this: UnknownBlock) { + this.jsonInit({ + extensions: ['colours_unknown'] + }); + this.placeholderText = ''; + + this.blockInfo = {}; + this.updateDisplay_(); + this.setMovable(false); + console.log('init'); + }, + updateDisplay_: function(this: UnknownBlock) { + this.removeAllInputs(); + this.updateShape_(); + this.setPlaceholderText_(this.placeholderText); + }, + removeAllInputs(this: UnknownBlock) { + // Delete inputs directly instead of with block.removeInput to avoid splicing + // out of the input list at every index. + for (const input of this.inputList) { + input.dispose(); + } + this.inputList = []; + }, + updateShape_: function(this: UnknownBlock) { + this.setOutputShape(Constants.OUTPUT_SHAPE_NORMAL); + this.setOutput(true); + this.setPreviousStatement(true); + this.setNextStatement(true); + }, + setPlaceholderText_: function(this: UnknownBlock, text: string) { + this.appendDummyInput().appendField(text); + }, + saveExtraState: function(this: UnknownBlock): UnknownBlockExtraState { + return { + blockInfo: this.blockInfo, + placeholderText: this.placeholderText + }; + }, + loadExtraState: function(this: UnknownBlock, state: UnknownBlockExtraState) { + this.blockInfo = state.blockInfo; + this.placeholderText = state.placeholderText; + this.updateDisplay_(); + } +} as UnknownBlock; diff --git a/packages/block/src/blocks/extensions.ts b/packages/block/src/blocks/extensions.ts index 4a9625fbd..cab984b49 100644 --- a/packages/block/src/blocks/extensions.ts +++ b/packages/block/src/blocks/extensions.ts @@ -126,7 +126,7 @@ const SCRATCH_EXTENSION = function(this: Blockly.Block) { const registerAll = function() { const categoryNames = ['control', 'data', 'data_lists', 'sounds', 'motion', 'looks', 'event', - 'sensing', 'pen', 'operators', 'more', 'argument']; + 'sensing', 'pen', 'operators', 'more', 'argument', 'unknown']; // Register functions for all category colours. for (const name of categoryNames) { Blockly.Extensions.register('colours_' + name, colourHelper(name)); diff --git a/packages/block/src/colours.ts b/packages/block/src/colours.ts index 507947ba9..ee33fbd5e 100644 --- a/packages/block/src/colours.ts +++ b/packages/block/src/colours.ts @@ -97,6 +97,12 @@ export const Colours: Record | string | number> = tertiary: '#EE3645', quaternary: '#EE3645' }, + unknown: { + primary: '#FF0000', + secondary: '#CC0000', + tertiary: '#AA0000', + quaternary: '#AA0000' + }, text: '#FFFFFF', workspace: '#F9F9F9', toolboxHover: '#4C97FF', @@ -191,6 +197,11 @@ const blockStyles: {[key: string]: Partial} = { colourSecondary: '#F15764', colourTertiary: '#EE3645' }, + unknown: { + colourPrimary: '#FF0000', + colourSecondary: '#CC0000', + colourTertiary: '#AA0000' + }, textField: { colourPrimary: '#FFFFFF' } From d929338959f59def38791ef2b70df97e2d28d555 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 26 Nov 2025 11:48:34 +0800 Subject: [PATCH 2/7] :wrench: chore(block): change shape for unknown blocks Signed-off-by: SimonShiki --- packages/block/src/blocks/common.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/block/src/blocks/common.ts b/packages/block/src/blocks/common.ts index fe211a0c0..3b003750d 100644 --- a/packages/block/src/blocks/common.ts +++ b/packages/block/src/blocks/common.ts @@ -242,14 +242,13 @@ Blockly.Blocks['unknown'] = { this.blockInfo = {}; this.updateDisplay_(); this.setMovable(false); - console.log('init'); }, updateDisplay_: function(this: UnknownBlock) { this.removeAllInputs(); this.updateShape_(); this.setPlaceholderText_(this.placeholderText); }, - removeAllInputs(this: UnknownBlock) { + removeAllInputs: function(this: UnknownBlock) { // Delete inputs directly instead of with block.removeInput to avoid splicing // out of the input list at every index. for (const input of this.inputList) { @@ -258,10 +257,23 @@ Blockly.Blocks['unknown'] = { this.inputList = []; }, updateShape_: function(this: UnknownBlock) { - this.setOutputShape(Constants.OUTPUT_SHAPE_NORMAL); + if (this.getNextBlock() || this.getPreviousBlock()) { + this.setOutputShape(Constants.OUTPUT_SHAPE_NORMAL); + this.setOutput(false); + this.setPreviousStatement(true); + this.setNextStatement(true); + } else if (this.outputConnection?.isConnected()) { + const isBoolean = this.outputConnection?.targetConnection?.getCheck()?. includes('Boolean'); + this.setOutputShape(isBoolean ? Constants.OUTPUT_SHAPE_HEXAGONAL : Constants. OUTPUT_SHAPE_ROUND); + this.setOutput(true); + this.setPreviousStatement(false); + this.setNextStatement(false); + } else { + this.setOutputShape(Constants.OUTPUT_SHAPE_NORMAL); this.setOutput(true); this.setPreviousStatement(true); this.setNextStatement(true); + } }, setPlaceholderText_: function(this: UnknownBlock, text: string) { this.appendDummyInput().appendField(text); @@ -276,5 +288,15 @@ Blockly.Blocks['unknown'] = { this.blockInfo = state.blockInfo; this.placeholderText = state.placeholderText; this.updateDisplay_(); + }, + onchange: function(this: UnknownBlock, event: Blockly.Events.Abstract) { + switch (event.type) { + case 'change': + case 'create': + case 'delete': + case 'move': + this.updateShape_(); + break; + } } } as UnknownBlock; From af5c6a1ecd723bd9455dd10cf9dd86f44144d8db Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 26 Nov 2025 12:38:51 +0800 Subject: [PATCH 3/7] :wrench: chore(block): refine code logic Signed-off-by: SimonShiki --- packages/block/src/blocks/common.ts | 100 +++++++++++++++++++++------- 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/packages/block/src/blocks/common.ts b/packages/block/src/blocks/common.ts index 3b003750d..a194217c7 100644 --- a/packages/block/src/blocks/common.ts +++ b/packages/block/src/blocks/common.ts @@ -212,12 +212,37 @@ Blockly.Blocks['text'] = { /* Special Blocks */ interface UnknownBlock extends Blockly.BlockSvg { + /** + * Used to retain unknown block info. + */ blockInfo: Record; + /** + * User-friendly placeholder text to show on the block. + */ placeholderText: string; + /** + * Current shape of the block. + */ + shape: number | null; + /** + * Force update display based on current shape and placeholder text. + */ updateDisplay_: () => void; - removeAllInputs: () => void; - updateShape_: () => void; + /** + * Infer what shape should we are, based on connection status. + * @returns Constants.OUTPUT_SHAPE_*, or -1 if unable to infer the shape. + */ + inferShape_: () => number | null; + /** + * Change block shape based on given shape. + * @param shape The shape to change to. accepts Constants.OUTPUT_SHAPE_* and -1; + */ + updateShape_: (shape: number | null) => void; + /** + * Set the placeholder text shown on the block. usually use the unknown block's type. + * @param text The placeholder text to set. + */ setPlaceholderText_: (text: string) => void; } @@ -238,45 +263,66 @@ Blockly.Blocks['unknown'] = { extensions: ['colours_unknown'] }); this.placeholderText = ''; - this.blockInfo = {}; - this.updateDisplay_(); + // Init shape; placeholder text is empty by default and will be set by loadExtraState. + this.shape = this.inferShape_(); + this.updateShape_(this.shape); + // Unknown blocks are not movable. Since we don't know their real shape and connections, + // allowing moving may break the workspace when the block becomes 'known'. this.setMovable(false); }, updateDisplay_: function(this: UnknownBlock) { - this.removeAllInputs(); - this.updateShape_(); + this.updateShape_(this.shape); this.setPlaceholderText_(this.placeholderText); }, - removeAllInputs: function(this: UnknownBlock) { - // Delete inputs directly instead of with block.removeInput to avoid splicing - // out of the input list at every index. - for (const input of this.inputList) { - input.dispose(); + inferShape_: function(this: UnknownBlock) { + if (this.getNextBlock() || this.getPreviousBlock()) { // If it has next/previous block + return Constants.OUTPUT_SHAPE_NORMAL; + } else if (this.outputConnection?.isConnected()) { // If we're connected to other block + // Shape based on the type of block we're connected to. Boolean is the only special case in Scratch. + const isBoolean = this.outputConnection?.targetConnection?.getCheck()?.includes('Boolean'); + return isBoolean ? Constants.OUTPUT_SHAPE_HEXAGONAL : Constants.OUTPUT_SHAPE_ROUND; + } else { + return -1; // Indicate we don't know change to which shape } - this.inputList = []; }, - updateShape_: function(this: UnknownBlock) { - if (this.getNextBlock() || this.getPreviousBlock()) { + updateShape_: function(this: UnknownBlock, shape: number | null) { + switch (shape) { + case Constants.OUTPUT_SHAPE_NORMAL: this.setOutputShape(Constants.OUTPUT_SHAPE_NORMAL); this.setOutput(false); this.setPreviousStatement(true); this.setNextStatement(true); - } else if (this.outputConnection?.isConnected()) { - const isBoolean = this.outputConnection?.targetConnection?.getCheck()?. includes('Boolean'); - this.setOutputShape(isBoolean ? Constants.OUTPUT_SHAPE_HEXAGONAL : Constants. OUTPUT_SHAPE_ROUND); + return; + case Constants.OUTPUT_SHAPE_HEXAGONAL: + this.setOutputShape(Constants.OUTPUT_SHAPE_HEXAGONAL); this.setOutput(true); this.setPreviousStatement(false); this.setNextStatement(false); - } else { + return; + case Constants.OUTPUT_SHAPE_ROUND: + this.setOutputShape(Constants.OUTPUT_SHAPE_ROUND); + this.setOutput(true); + this.setPreviousStatement(false); + this.setNextStatement(false); + return; + default: + // Unable to infer shape, so we allow it connected in any way. this.setOutputShape(Constants.OUTPUT_SHAPE_NORMAL); - this.setOutput(true); - this.setPreviousStatement(true); - this.setNextStatement(true); + this.setOutput(true); + this.setPreviousStatement(true); + this.setNextStatement(true); } }, setPlaceholderText_: function(this: UnknownBlock, text: string) { - this.appendDummyInput().appendField(text); + const input = this.getInput('PLACEHOLDER') ?? this.appendDummyInput('PLACEHOLDER'); + for (const field of input.fieldRow) { + if (field.name === 'PLACEHOLDER_TEXT') { + field.setValue(text); + return; + } + } + input.appendField(text, 'PLACEHOLDER_TEXT'); }, saveExtraState: function(this: UnknownBlock): UnknownBlockExtraState { return { @@ -287,16 +333,20 @@ Blockly.Blocks['unknown'] = { loadExtraState: function(this: UnknownBlock, state: UnknownBlockExtraState) { this.blockInfo = state.blockInfo; this.placeholderText = state.placeholderText; - this.updateDisplay_(); + this.setPlaceholderText_(this.placeholderText); }, onchange: function(this: UnknownBlock, event: Blockly.Events.Abstract) { + // Only respond to events that may change connection status switch (event.type) { case 'change': case 'create': case 'delete': case 'move': - this.updateShape_(); - break; + if (this.shape !== this.inferShape_()) { + this.shape = this.inferShape_(); + this.updateShape_(this.shape); + } + break; } } } as UnknownBlock; From 61a9885495a5996d13742d2137635d041c39c35e Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 26 Nov 2025 12:42:22 +0800 Subject: [PATCH 4/7] :wrench: chore(block): remove useless nullish accessing operator Signed-off-by: SimonShiki --- packages/block/src/blocks/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block/src/blocks/common.ts b/packages/block/src/blocks/common.ts index a194217c7..8ceef7a40 100644 --- a/packages/block/src/blocks/common.ts +++ b/packages/block/src/blocks/common.ts @@ -280,7 +280,7 @@ Blockly.Blocks['unknown'] = { return Constants.OUTPUT_SHAPE_NORMAL; } else if (this.outputConnection?.isConnected()) { // If we're connected to other block // Shape based on the type of block we're connected to. Boolean is the only special case in Scratch. - const isBoolean = this.outputConnection?.targetConnection?.getCheck()?.includes('Boolean'); + const isBoolean = this.outputConnection.targetConnection?.getCheck()?.includes('Boolean'); return isBoolean ? Constants.OUTPUT_SHAPE_HEXAGONAL : Constants.OUTPUT_SHAPE_ROUND; } else { return -1; // Indicate we don't know change to which shape From aba920e49a9650117383c91ec62848b2c4b1ffad Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 26 Nov 2025 16:03:21 +0800 Subject: [PATCH 5/7] :wrench: chore(block): placeholder now based on unknown block state Signed-off-by: SimonShiki --- packages/block/src/blocks/common.ts | 57 ++++++++++++++--------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/packages/block/src/blocks/common.ts b/packages/block/src/blocks/common.ts index 8ceef7a40..c4829fd45 100644 --- a/packages/block/src/blocks/common.ts +++ b/packages/block/src/blocks/common.ts @@ -211,15 +211,11 @@ Blockly.Blocks['text'] = { /* Special Blocks */ -interface UnknownBlock extends Blockly.BlockSvg { +export interface UnknownBlock extends Blockly.BlockSvg { /** - * Used to retain unknown block info. + * Used to retain unknown block state. */ - blockInfo: Record; - /** - * User-friendly placeholder text to show on the block. - */ - placeholderText: string; + unknownBlockState: Blockly.serialization.blocks.State | null; /** * Current shape of the block. */ @@ -243,13 +239,10 @@ interface UnknownBlock extends Blockly.BlockSvg { * Set the placeholder text shown on the block. usually use the unknown block's type. * @param text The placeholder text to set. */ - setPlaceholderText_: (text: string) => void; + updatePlaceholderText_: () => void; } -interface UnknownBlockExtraState { - blockInfo: Record; - placeholderText: string; -} +export type UnknownBlockExtraState = Blockly.serialization.blocks.State; /** * Placeholder block for non-existing blocks in clipcc-block. @@ -258,12 +251,11 @@ interface UnknownBlockExtraState { * WARNING: this block should only exists in blockly side, VM should never see this block. */ Blockly.Blocks['unknown'] = { - init: function(this: UnknownBlock) { + init: function() { this.jsonInit({ extensions: ['colours_unknown'] }); - this.placeholderText = ''; - this.blockInfo = {}; + this.unknownBlockState = null; // Init shape; placeholder text is empty by default and will be set by loadExtraState. this.shape = this.inferShape_(); this.updateShape_(this.shape); @@ -271,11 +263,11 @@ Blockly.Blocks['unknown'] = { // allowing moving may break the workspace when the block becomes 'known'. this.setMovable(false); }, - updateDisplay_: function(this: UnknownBlock) { + updateDisplay_: function() { this.updateShape_(this.shape); - this.setPlaceholderText_(this.placeholderText); + this.updatePlaceholderText_(); }, - inferShape_: function(this: UnknownBlock) { + inferShape_: function() { if (this.getNextBlock() || this.getPreviousBlock()) { // If it has next/previous block return Constants.OUTPUT_SHAPE_NORMAL; } else if (this.outputConnection?.isConnected()) { // If we're connected to other block @@ -286,7 +278,7 @@ Blockly.Blocks['unknown'] = { return -1; // Indicate we don't know change to which shape } }, - updateShape_: function(this: UnknownBlock, shape: number | null) { + updateShape_: function(shape: number | null) { switch (shape) { case Constants.OUTPUT_SHAPE_NORMAL: this.setOutputShape(Constants.OUTPUT_SHAPE_NORMAL); @@ -314,7 +306,8 @@ Blockly.Blocks['unknown'] = { this.setNextStatement(true); } }, - setPlaceholderText_: function(this: UnknownBlock, text: string) { + updatePlaceholderText_: function() { + const text = this.unknownBlockState?.type ?? 'unknown'; const input = this.getInput('PLACEHOLDER') ?? this.appendDummyInput('PLACEHOLDER'); for (const field of input.fieldRow) { if (field.name === 'PLACEHOLDER_TEXT') { @@ -324,24 +317,28 @@ Blockly.Blocks['unknown'] = { } input.appendField(text, 'PLACEHOLDER_TEXT'); }, - saveExtraState: function(this: UnknownBlock): UnknownBlockExtraState { - return { - blockInfo: this.blockInfo, - placeholderText: this.placeholderText - }; + saveExtraState: function(): UnknownBlockExtraState { + if (!this.unknownBlockState) { + throw new Error('Unknown block unknownBlockState is null when saving extra state.'); + } + return this.unknownBlockState; }, - loadExtraState: function(this: UnknownBlock, state: UnknownBlockExtraState) { - this.blockInfo = state.blockInfo; - this.placeholderText = state.placeholderText; - this.setPlaceholderText_(this.placeholderText); + loadExtraState: function(state: UnknownBlockExtraState) { + this.unknownBlockState = state; + this.updatePlaceholderText_(); }, - onchange: function(this: UnknownBlock, event: Blockly.Events.Abstract) { + onchange: function(event: Blockly.Events.Abstract) { // Only respond to events that may change connection status switch (event.type) { case 'change': case 'create': case 'delete': case 'move': + // see Blockly/core/connnection.ts:connect_ implementation + if (event.type === 'move' || (event as Blockly.Events.BlockMove).reason?.at(0) !== 'connect') { + return; + } + if (this.shape !== this.inferShape_()) { this.shape = this.inferShape_(); this.updateShape_(this.shape); From 63c6c517e1e3d699293cbcaac4abd681ea7b0040 Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Wed, 26 Nov 2025 21:58:31 +0800 Subject: [PATCH 6/7] :sparkles: feat(block): use unknown block in save/ & load workspace Signed-off-by: SimonShiki --- packages/block/src/blocks/common.ts | 5 - packages/block/src/index.ts | 25 +-- .../block/src/serialization/workspaces.ts | 156 ++++++++++++++++++ 3 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 packages/block/src/serialization/workspaces.ts diff --git a/packages/block/src/blocks/common.ts b/packages/block/src/blocks/common.ts index c4829fd45..bdddc2f07 100644 --- a/packages/block/src/blocks/common.ts +++ b/packages/block/src/blocks/common.ts @@ -334,11 +334,6 @@ Blockly.Blocks['unknown'] = { case 'create': case 'delete': case 'move': - // see Blockly/core/connnection.ts:connect_ implementation - if (event.type === 'move' || (event as Blockly.Events.BlockMove).reason?.at(0) !== 'connect') { - return; - } - if (this.shape !== this.inferShape_()) { this.shape = this.inferShape_(); this.updateShape_(this.shape); diff --git a/packages/block/src/index.ts b/packages/block/src/index.ts index 62f47e1d7..529866b76 100644 --- a/packages/block/src/index.ts +++ b/packages/block/src/index.ts @@ -135,30 +135,7 @@ export function injectWorkspace(container: Element | string, options?: Blockly.B return Blockly.inject(container, options); } -/** - * Returns the state of the workspace as a plain JavaScript object. - * @param workspace The workspace to serialize. - * @returns The serialized state of the workspace. - */ -export function saveWorkspace(workspace: Blockly.Workspace) { - return Blockly.serialization.workspaces.save(workspace); -} - -/** - * Loads the variable represented by the given state into the given workspace. - * @param state The state of the workspace to deserialize into the workspace. - * @param workspace The workspace to add the new state to. - * @param recordUndo If true, events triggered by this function will be - * undo-able by the user. False by default. - */ -export function loadWorkspace( - state: {[key: string]: unknown}, - workspace: Blockly.Workspace, - recordUndo?: boolean -) { - Blockly.serialization.workspaces.load(state, workspace, {recordUndo}); -} - +export {saveWorkspace, loadWorkspace} from './serialization/workspaces'; export {setExternalProcedureDefCallback} from './procedures_category'; // Monkey-patches diff --git a/packages/block/src/serialization/workspaces.ts b/packages/block/src/serialization/workspaces.ts new file mode 100644 index 000000000..dc69da40f --- /dev/null +++ b/packages/block/src/serialization/workspaces.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Clip Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Blockly from 'blockly/core'; + +type BlockState = Blockly.serialization.blocks.State; + +/** + * Check if a block type exists in the Blockly.Blocks registry. + * @param type The block type to check. + * @returns True if the block type exists. + */ +function blockExists(type: string): boolean { + return Object.prototype.hasOwnProperty.call(Blockly.Blocks, type); +} + +/** + * Recursively convert unknown blocks in state back to their original types. + * This restores the original block state from the extraState of unknown blocks. + * @param blockState The block state to convert. + * @returns The converted block state with unknown blocks restored. + */ +function convertUnknownToOriginal(blockState: BlockState): BlockState { + if (blockState.type === 'unknown' && blockState.extraState) { + // The extraState contains the original block state + const originalState = blockState.extraState as BlockState; + // Process the restored state recursively in case it has nested blocks + return convertUnknownToOriginal(originalState); + } + + // Process nested blocks in inputs + if (blockState.inputs) { + for (const inputName in blockState.inputs) { + if (!Object.prototype.hasOwnProperty.call(blockState.inputs, inputName)) continue; + const input = blockState.inputs[inputName]; + if (input.block) { + input.block = convertUnknownToOriginal(input.block); + } + if (input.shadow) { + input.shadow = convertUnknownToOriginal(input.shadow); + } + } + } + + // Process next block + if (blockState.next?.block) { + blockState.next.block = convertUnknownToOriginal(blockState.next.block); + } + + return blockState; +} + +/** + * Recursively convert non-existing blocks to unknown blocks. + * This preserves the original block state in the extraState of unknown blocks. + * @param blockState The block state to convert. + * @returns The converted block state with non-existing blocks as unknown. + */ +function convertToUnknown(blockState: BlockState): BlockState { + // Process nested blocks in inputs first (depth-first) + if (blockState.inputs) { + for (const inputName in blockState.inputs) { + if (!Object.prototype.hasOwnProperty.call(blockState.inputs, inputName)) continue; + const input = blockState.inputs[inputName]; + if (input.block) { + input.block = convertToUnknown(input.block); + } + if (input.shadow) { + input.shadow = convertToUnknown(input.shadow); + } + } + } + + // Process next block + if (blockState.next?.block) { + blockState.next.block = convertToUnknown(blockState.next.block); + } + + // Check if this block type exists + if (!blockExists(blockState.type)) { + // Store the original state (with already converted children) as extraState + const originalState = {...blockState}; + return { + type: 'unknown', + id: blockState.id, + x: blockState.x, + y: blockState.y, + extraState: originalState + }; + } + + return blockState; +} + +/** + * Convert all non-existing blocks in workspace state to unknown blocks. + * @param state The workspace state to process. + * @returns The processed workspace state. + */ +function convertWorkspaceStateToUnknown(state: {[key: string]: unknown}): {[key: string]: unknown} { + const result = {...state}; + if (result.blocks && typeof result.blocks === 'object') { + const blocks = result.blocks as {blocks?: BlockState[]}; + if (blocks.blocks && Array.isArray(blocks.blocks)) { + blocks.blocks = blocks.blocks.map(convertToUnknown); + } + } + return result; +} + +/** + * Convert all unknown blocks in workspace state back to their original types. + * @param state The workspace state to process. + * @returns The processed workspace state. + */ +function convertWorkspaceStateFromUnknown(state: {[key: string]: unknown}): {[key: string]: unknown} { + const result = {...state}; + if (result.blocks && typeof result.blocks === 'object') { + const blocks = result.blocks as {blocks?: BlockState[]}; + if (blocks.blocks && Array.isArray(blocks.blocks)) { + blocks.blocks = blocks.blocks.map(convertUnknownToOriginal); + } + } + return result; +} + +/** + * Returns the state of the workspace as a plain JavaScript object. + * Unknown blocks are converted back to their original block types. + * @param workspace The workspace to serialize. + * @returns The serialized state of the workspace. + */ +export function saveWorkspace(workspace: Blockly.Workspace) { + const state = Blockly.serialization.workspaces.save(workspace); + return convertWorkspaceStateFromUnknown(state); +} + +/** + * Loads the variable represented by the given state into the given workspace. + * Non-existing block types are converted to unknown blocks to preserve their data. + * @param state The state of the workspace to deserialize into the workspace. + * @param workspace The workspace to add the new state to. + * @param recordUndo If true, events triggered by this function will be + * undo-able by the user. False by default. + */ +export function loadWorkspace( + state: { [key: string]: unknown }, + workspace: Blockly.Workspace, + recordUndo?: boolean +) { + const processedState = convertWorkspaceStateToUnknown(state); + Blockly.serialization.workspaces.load(processedState, workspace, {recordUndo}); +} From b94f59ef497599893550c691426ec090e9458efb Mon Sep 17 00:00:00 2001 From: SimonShiki Date: Thu, 27 Nov 2025 10:45:09 +0800 Subject: [PATCH 7/7] :wrench: chore(block): remove unnecessary deepcopy Signed-off-by: SimonShiki --- .../block/src/serialization/workspaces.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/block/src/serialization/workspaces.ts b/packages/block/src/serialization/workspaces.ts index dc69da40f..60d553c40 100644 --- a/packages/block/src/serialization/workspaces.ts +++ b/packages/block/src/serialization/workspaces.ts @@ -82,13 +82,12 @@ function convertToUnknown(blockState: BlockState): BlockState { // Check if this block type exists if (!blockExists(blockState.type)) { // Store the original state (with already converted children) as extraState - const originalState = {...blockState}; return { type: 'unknown', id: blockState.id, x: blockState.x, y: blockState.y, - extraState: originalState + extraState: blockState }; } @@ -100,15 +99,14 @@ function convertToUnknown(blockState: BlockState): BlockState { * @param state The workspace state to process. * @returns The processed workspace state. */ -function convertWorkspaceStateToUnknown(state: {[key: string]: unknown}): {[key: string]: unknown} { - const result = {...state}; - if (result.blocks && typeof result.blocks === 'object') { - const blocks = result.blocks as {blocks?: BlockState[]}; +function convertWorkspaceStateToUnknown(state: Record): Record { + if (typeof state.blocks === 'object') { + const blocks = state.blocks as {blocks?: BlockState[]}; if (blocks.blocks && Array.isArray(blocks.blocks)) { blocks.blocks = blocks.blocks.map(convertToUnknown); } } - return result; + return state; } /** @@ -116,15 +114,14 @@ function convertWorkspaceStateToUnknown(state: {[key: string]: unknown}): {[key: * @param state The workspace state to process. * @returns The processed workspace state. */ -function convertWorkspaceStateFromUnknown(state: {[key: string]: unknown}): {[key: string]: unknown} { - const result = {...state}; - if (result.blocks && typeof result.blocks === 'object') { - const blocks = result.blocks as {blocks?: BlockState[]}; +function convertWorkspaceStateFromUnknown(state: Record): Record { + if (typeof state.blocks === 'object') { + const blocks = state.blocks as {blocks?: BlockState[]}; if (blocks.blocks && Array.isArray(blocks.blocks)) { blocks.blocks = blocks.blocks.map(convertUnknownToOriginal); } } - return result; + return state; } /** @@ -147,10 +144,10 @@ export function saveWorkspace(workspace: Blockly.Workspace) { * undo-able by the user. False by default. */ export function loadWorkspace( - state: { [key: string]: unknown }, + state: Record, workspace: Blockly.Workspace, recordUndo?: boolean ) { - const processedState = convertWorkspaceStateToUnknown(state); - Blockly.serialization.workspaces.load(processedState, workspace, {recordUndo}); + convertWorkspaceStateToUnknown(state); + Blockly.serialization.workspaces.load(state, workspace, {recordUndo}); }