Deep Understanding Google Blockly - Part 4: Advanced Topics
A comprehensive technical deep-dive into Blockly's advanced systems
Part 4 of 4
Table of Contents
- Introduction
- The Event System
- The Connection System
- Serialization & Deserialization
- The Field System
- Workspace Management
- Plugin Architecture
- Performance Optimization
Introduction
This final part explores Blockly's advanced systems that enable:
- Real-time event handling with undo/redo support
- Type-safe connections between blocks
- Serialization to JSON and XML formats
- Custom fields for specialized inputs
- Multi-workspace management
- Extensibility through plugins
The Event System
Event Architecture
Blockly uses an event-driven architecture where every action fires events that can be:
- Listened to for custom behavior
- Recorded for undo/redo
- Filtered to prevent propagation
- Replayed to restore state
Event Types
From core/events/type.ts:
export enum EventType {
// Block events
BLOCK_CHANGE = 'change',
BLOCK_CREATE = 'create',
BLOCK_DELETE = 'delete',
BLOCK_MOVE = 'move',
BLOCK_DRAG = 'drag',
// UI events
SELECTED = 'selected',
CLICK = 'click',
// Variable events
VAR_CREATE = 'var_create',
VAR_DELETE = 'var_delete',
VAR_RENAME = 'var_rename',
// Comment events
COMMENT_CREATE = 'comment_create',
COMMENT_DELETE = 'comment_delete',
COMMENT_CHANGE = 'comment_change',
COMMENT_MOVE = 'comment_move',
// Workspace events
VIEWPORT_CHANGE = 'viewport_change',
THEME_CHANGE = 'theme_change',
TOOLBOX_ITEM_SELECT = 'toolbox_item_select',
}
Event Base Class
export abstract class AbstractEvent {
workspaceId: string;
group: string;
recordUndo: boolean;
constructor(workspaceId: string) {
this.workspaceId = workspaceId;
this.group = eventUtils.getGroup();
this.recordUndo = eventUtils.getRecordUndo();
}
abstract toJson(): object;
abstract fromJson(json: object): void;
abstract run(forward: boolean): void; // For undo/redo
}
Listening to Events
workspace.addChangeListener((event: AbstractEvent) => {
if (event.type === Blockly.Events.BLOCK_MOVE) {
console.log('Block moved:', event);
}
if (event.type === Blockly.Events.BLOCK_CHANGE) {
const changeEvent = event as Blockly.Events.BlockChange;
console.log('Field changed:', changeEvent.name, changeEvent.newValue);
}
});
Event Groups
// Group multiple events for single undo
Blockly.Events.setGroup(true);
try {
block1.moveBy(10, 10);
block2.setFieldValue('new value', 'FIELD');
block3.dispose();
} finally {
Blockly.Events.setGroup(false);
}
Undo/Redo System
// Enable undo
workspace.undo(false); // Undo last action
workspace.undo(true); // Redo last undone action
// Clear undo stack
workspace.clearUndo();
// Disable recording temporarily
Blockly.Events.setRecordUndo(false);
// ... make changes ...
Blockly.Events.setRecordUndo(true);
The Connection System
Connection Types
enum ConnectionType {
INPUT_VALUE = 1, // Accepts value blocks (puzzle tab)
OUTPUT_VALUE = 2, // Provides value (puzzle tab)
NEXT_STATEMENT = 3, // Bottom of block (notch)
PREVIOUS_STATEMENT = 4 // Top of block (notch)
}
Connection Class
export class Connection {
type: number;
sourceBlock_: Block;
targetConnection: Connection | null = null;
private check: string[] | null = null;
x = 0; // Position
y = 0;
constructor(source: Block, type: number) {
this.sourceBlock_ = source;
this.type = type;
}
connect(otherConnection: Connection): boolean {
const checker = this.getConnectionChecker();
if (!checker.canConnect(this, otherConnection, false)) {
return false;
}
// Group events
const existingGroup = eventUtils.getGroup();
if (!existingGroup) {
eventUtils.setGroup(true);
}
try {
this.connect_(otherConnection);
return true;
} finally {
if (!existingGroup) {
eventUtils.setGroup(false);
}
}
}
setCheck(check: string | string[] | null): Connection {
if (check) {
this.check = Array.isArray(check) ? check : [check];
} else {
this.check = null;
}
return this;
}
}
Type Checking
// Define block with type checking
Blockly.Blocks['math_number'] = {
init: function() {
this.appendDummyInput()
.appendField(new Blockly.FieldNumber(0), 'NUM');
this.setOutput(true, 'Number'); // Output type
this.setColour(230);
}
};
Blockly.Blocks['math_arithmetic'] = {
init: function() {
this.appendValueInput('A')
.setCheck('Number'); // Only accepts Number type
this.appendValueInput('B')
.setCheck('Number');
this.setOutput(true, 'Number');
this.setColour(230);
}
};
Connection Database
export class ConnectionDB {
private connections_: RenderedConnection[] = [];
addConnection(connection: RenderedConnection) {
this.connections_.push(connection);
}
removeConnection(connection: RenderedConnection) {
const index = this.connections_.indexOf(connection);
if (index !== -1) {
this.connections_.splice(index, 1);
}
}
getNeighbours(connection: RenderedConnection, maxRadius: number) {
const result: RenderedConnection[] = [];
for (const conn of this.connections_) {
if (conn === connection) continue;
const dx = connection.x - conn.x;
const dy = connection.y - conn.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance <= maxRadius) {
result.push(conn);
}
}
return result;
}
}
Serialization & Deserialization
JSON Serialization
From core/serialization/blocks.ts:
export function save(block: Block, params?: SaveParams): State {
const state: State = {
type: block.type,
id: block.id,
};
// Save position for top-level blocks
if (!block.getParent()) {
state.x = Math.round(block.getRelativeToSurfaceXY().x);
state.y = Math.round(block.getRelativeToSurfaceXY().y);
}
// Save fields
for (const input of block.inputList) {
for (const field of input.fieldRow) {
if (field.SERIALIZABLE) {
if (!state.fields) state.fields = {};
state.fields[field.name!] = field.saveState();
}
}
}
// Save inputs
for (const input of block.inputList) {
if (input.connection) {
const targetBlock = input.connection.targetBlock();
if (targetBlock) {
if (!state.inputs) state.inputs = {};
state.inputs[input.name] = {
block: save(targetBlock, params),
};
}
}
}
// Save next block
if (block.nextConnection) {
const nextBlock = block.nextConnection.targetBlock();
if (nextBlock) {
state.next = {
block: save(nextBlock, params),
};
}
}
// Save extra state (mutations)
if (block.saveExtraState) {
state.extraState = block.saveExtraState();
}
return state;
}
XML Serialization
export function blockToDom(block: Block, opt_noId?: boolean): Element {
const element = xmlUtils.createElement('block');
element.setAttribute('type', block.type);
if (!opt_noId) {
element.setAttribute('id', block.id);
}
// Position
if (!block.getParent()) {
const xy = block.getRelativeToSurfaceXY();
element.setAttribute('x', String(Math.round(xy.x)));
element.setAttribute('y', String(Math.round(xy.y)));
}
// Fields
for (const input of block.inputList) {
for (const field of input.fieldRow) {
if (field.SERIALIZABLE) {
const fieldDom = fieldToDom_(field);
if (fieldDom) {
element.appendChild(fieldDom);
}
}
}
}
// Mutation
if (block.mutationToDom) {
const mutation = block.mutationToDom();
if (mutation) {
element.appendChild(mutation);
}
}
return element;
}
Workspace Serialization
// Save to JSON
const state = Blockly.serialization.workspaces.save(workspace);
const json = JSON.stringify(state);
// Load from JSON
const state = JSON.parse(json);
Blockly.serialization.workspaces.load(state, workspace);
// Save to XML
const xml = Blockly.Xml.workspaceToDom(workspace);
const xmlText = Blockly.Xml.domToText(xml);
// Load from XML
const xml = Blockly.utils.xml.textToDom(xmlText);
Blockly.Xml.domToWorkspace(xml, workspace);
The Field System
Field Base Class
export abstract class Field<T = any> implements ISerializable {
DEFAULT_VALUE: T | null = null;
EDITABLE = true;
SERIALIZABLE = false;
protected value_: T | null;
protected sourceBlock_: Block | null = null;
protected fieldGroup_: SVGGElement | null = null;
constructor(value: T, validator?: FieldValidator<T>) {
this.value_ = this.DEFAULT_VALUE;
this.setValue(value);
if (validator) {
this.setValidator(validator);
}
}
setValue(newValue: T) {
const validated = this.doClassValidation_(newValue);
if (validated === null) {
return; // Invalid
}
const oldValue = this.value_;
this.value_ = validated;
if (this.sourceBlock_ && Blockly.Events.isEnabled()) {
Blockly.Events.fire(
new Blockly.Events.BlockChange(
this.sourceBlock_, 'field', this.name!, oldValue, validated
)
);
}
this.render_();
}
abstract fromXml(fieldElement: Element): void;
abstract toXml(fieldElement: Element): Element;
protected abstract render_(): void;
}
Custom Field Example
class FieldColor extends Field<string> {
DEFAULT_VALUE = '#ffffff';
SERIALIZABLE = true;
constructor(value?: string, validator?: FieldValidator<string>) {
super(value || '#ffffff', validator);
}
protected render_() {
if (!this.fieldGroup_) return;
// Create color preview rectangle
const rect = this.fieldGroup_.querySelector('rect');
if (rect) {
rect.style.fill = this.value_ || this.DEFAULT_VALUE;
}
}
showEditor_() {
// Show color picker
const colorPicker = document.createElement('input');
colorPicker.type = 'color';
colorPicker.value = this.value_ || this.DEFAULT_VALUE;
colorPicker.addEventListener('change', () => {
this.setValue(colorPicker.value);
});
colorPicker.click();
}
fromXml(fieldElement: Element) {
this.setValue(fieldElement.textContent || this.DEFAULT_VALUE);
}
toXml(fieldElement: Element): Element {
fieldElement.textContent = this.value_ || this.DEFAULT_VALUE;
return fieldElement;
}
}
// Register field
Blockly.fieldRegistry.register('field_color', FieldColor);
Workspace Management
WorkspaceSvg Class
export class WorkspaceSvg extends Workspace {
scale = 1;
scrollX = 0;
scrollY = 0;
private toolbox: IToolbox | null = null;
private flyout: IFlyout | null = null;
trashcan: Trashcan | null = null;
scrollbar: ScrollbarPair | null = null;
translate(x: number, y: number) {
const translation = 'translate(' + x + ',' + y + ') ' +
'scale(' + this.scale + ')';
this.getCanvas().setAttribute('transform', translation);
this.getBlockCanvas().setAttribute('transform', translation);
}
zoom(x: number, y: number, amount: number) {
const speed = this.options.zoomOptions.scaleSpeed || 1.2;
const newScale = amount > 0 ? this.scale * speed : this.scale / speed;
this.setScale(newScale);
}
setScale(newScale: number) {
const oldScale = this.scale;
this.scale = Math.max(
Math.min(newScale, this.options.zoomOptions.maxScale),
this.options.zoomOptions.minScale
);
if (oldScale !== this.scale) {
this.translate(this.scrollX, this.scrollY);
}
}
}
Multi-Workspace Example
// Main workspace
const mainWorkspace = Blockly.inject('mainDiv', {
toolbox: toolboxXml,
});
// Minimap workspace
const minimapWorkspace = Blockly.inject('minimapDiv', {
readOnly: true,
zoom: {
controls: false,
wheel: false,
startScale: 0.3,
},
});
// Sync main to minimap
mainWorkspace.addChangeListener((event) => {
if (event.type === Blockly.Events.BLOCK_CREATE ||
event.type === Blockly.Events.BLOCK_DELETE ||
event.type === Blockly.Events.BLOCK_MOVE) {
const state = Blockly.serialization.workspaces.save(mainWorkspace);
Blockly.serialization.workspaces.load(state, minimapWorkspace, {
recordUndo: false,
});
}
});
Plugin Architecture
Renderer Plugin
class CustomRenderer extends Blockly.blockRendering.Renderer {
constructor(name: string) {
super(name);
}
protected makeConstants_() {
return new CustomConstantProvider();
}
}
class CustomConstantProvider extends Blockly.blockRendering.ConstantProvider {
init() {
super.init();
// Customize appearance
this.NOTCH_WIDTH = 20;
this.NOTCH_HEIGHT = 5;
this.CORNER_RADIUS = 12;
}
}
// Register
Blockly.blockRendering.register('custom', CustomRenderer);
// Use
Blockly.inject('blocklyDiv', {
renderer: 'custom',
});
Field Plugin
class FieldSlider extends Blockly.Field {
constructor(value, min, max, validator) {
super(value, validator);
this.min_ = min || 0;
this.max_ = max || 100;
}
showEditor_() {
const slider = document.createElement('input');
slider.type = 'range';
slider.min = this.min_;
slider.max = this.max_;
slider.value = this.getValue();
Blockly.WidgetDiv.show(
this,
this.sourceBlock_.RTL,
() => { slider.focus(); }
);
const div = Blockly.WidgetDiv.getDiv();
div.appendChild(slider);
slider.addEventListener('input', () => {
this.setValue(parseInt(slider.value));
});
}
}
Blockly.fieldRegistry.register('field_slider', FieldSlider);
Performance Optimization
Batch Operations
// Disable events during bulk operations
Blockly.Events.disable();
try {
for (let i = 0; i < 1000; i++) {
workspace.newBlock('math_number');
}
} finally {
Blockly.Events.enable();
}
// Or use setGroup to batch undo
Blockly.Events.setGroup(true);
for (const block of blocks) {
block.moveBy(10, 0);
}
Blockly.Events.setGroup(false);
Render Batching
// Disable rendering
workspace.setResizesEnabled(false);
// Make multiple changes
block1.setFieldValue('value1', 'FIELD');
block2.moveBy(50, 50);
block3.appendInput('new_input');
// Re-enable and render once
workspace.setResizesEnabled(true);
workspace.render();
Summary
In this 4-part series, we've explored:
Part 1: Architecture & Blocks
- Core architecture and module organization
- Block class hierarchy and lifecycle
- Input system and block definitions
Part 2: Rendering System
- SVG rendering pipeline
- Renderer architecture (Geras, Thrasos, Zelos)
- Measurement and drawing phases
Part 3: Code Generation
- CodeGenerator base class
- Language-specific generators
- Operator precedence
- Custom generator functions
Part 4: Advanced Topics
- Event system and undo/redo
- Connection type checking
- Serialization (JSON/XML)
- Custom fields and plugins
- Performance optimization
Resources
- Official Documentation: https://developers.google.com/blockly
- Source Code: https://github.com/google/blockly
- Samples & Plugins: https://github.com/google/blockly-samples
- Developer Forum: https://groups.google.com/forum/#!forum/blockly
This article series is based on analysis of Blockly v12.3.1 source code.