diff --git a/packages/block/src/blocks/common.ts b/packages/block/src/blocks/common.ts index 2599dad4c..bdddc2f07 100644 --- a/packages/block/src/blocks/common.ts +++ b/packages/block/src/blocks/common.ts @@ -208,3 +208,137 @@ Blockly.Blocks['text'] = { }); } }; + +/* Special Blocks */ + +export interface UnknownBlock extends Blockly.BlockSvg { + /** + * Used to retain unknown block state. + */ + unknownBlockState: Blockly.serialization.blocks.State | null; + /** + * Current shape of the block. + */ + shape: number | null; + + /** + * Force update display based on current shape and placeholder text. + */ + updateDisplay_: () => 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. + */ + updatePlaceholderText_: () => void; +} + +export type UnknownBlockExtraState = Blockly.serialization.blocks.State; + +/** + * 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.jsonInit({ + extensions: ['colours_unknown'] + }); + 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); + // 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.updateShape_(this.shape); + this.updatePlaceholderText_(); + }, + 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 + // 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 + } + }, + updateShape_: function(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); + return; + case Constants.OUTPUT_SHAPE_HEXAGONAL: + this.setOutputShape(Constants.OUTPUT_SHAPE_HEXAGONAL); + this.setOutput(true); + this.setPreviousStatement(false); + this.setNextStatement(false); + 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); + } + }, + 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') { + field.setValue(text); + return; + } + } + input.appendField(text, 'PLACEHOLDER_TEXT'); + }, + saveExtraState: function(): UnknownBlockExtraState { + if (!this.unknownBlockState) { + throw new Error('Unknown block unknownBlockState is null when saving extra state.'); + } + return this.unknownBlockState; + }, + loadExtraState: function(state: UnknownBlockExtraState) { + this.unknownBlockState = state; + this.updatePlaceholderText_(); + }, + 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': + if (this.shape !== this.inferShape_()) { + this.shape = this.inferShape_(); + this.updateShape_(this.shape); + } + break; + } + } +} 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' } 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..60d553c40 --- /dev/null +++ b/packages/block/src/serialization/workspaces.ts @@ -0,0 +1,153 @@ +/** + * @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 + return { + type: 'unknown', + id: blockState.id, + x: blockState.x, + y: blockState.y, + extraState: blockState + }; + } + + 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: 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 state; +} + +/** + * 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: 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 state; +} + +/** + * 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: Record, + workspace: Blockly.Workspace, + recordUndo?: boolean +) { + convertWorkspaceStateToUnknown(state); + Blockly.serialization.workspaces.load(state, workspace, {recordUndo}); +}