Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions packages/block/src/blocks/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 1 addition & 1 deletion packages/block/src/blocks/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
11 changes: 11 additions & 0 deletions packages/block/src/colours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export const Colours: Record<string, Record<string, string> | string | number> =
tertiary: '#EE3645',
quaternary: '#EE3645'
},
unknown: {
primary: '#FF0000',
secondary: '#CC0000',
tertiary: '#AA0000',
quaternary: '#AA0000'
},
text: '#FFFFFF',
workspace: '#F9F9F9',
toolboxHover: '#4C97FF',
Expand Down Expand Up @@ -191,6 +197,11 @@ const blockStyles: {[key: string]: Partial<Blockly.Theme.BlockStyle>} = {
colourSecondary: '#F15764',
colourTertiary: '#EE3645'
},
unknown: {
colourPrimary: '#FF0000',
colourSecondary: '#CC0000',
colourTertiary: '#AA0000'
},
textField: {
colourPrimary: '#FFFFFF'
}
Expand Down
25 changes: 1 addition & 24 deletions packages/block/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions packages/block/src/serialization/workspaces.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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<string, unknown>,
workspace: Blockly.Workspace,
recordUndo?: boolean
) {
convertWorkspaceStateToUnknown(state);
Blockly.serialization.workspaces.load(state, workspace, {recordUndo});
}