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

  1. Introduction
  2. The Event System
  3. The Connection System
  4. Serialization & Deserialization
  5. The Field System
  6. Workspace Management
  7. Plugin Architecture
  8. 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


This article series is based on analysis of Blockly v12.3.1 source code.

Subscribe to Root Logic

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe