Skip to main content

Overview

Custom blocks allow you to extend AppFlowy Editor with new content types beyond the standard blocks. You can create blocks for any content: code blocks, callouts, embeds, interactive widgets, or domain-specific components.

Block Component Architecture

Every block component consists of three main parts:

1. Block Keys

Define constants for your block type and attributes:
class CustomBlockKeys {
  const CustomBlockKeys._();

  static const String type = 'custom_block';
  
  // Add custom attributes
  static const String customAttribute = 'customAttribute';
  static const String backgroundColor = blockComponentBackgroundColor;
}

2. Node Factory Function

Create a helper function to generate nodes:
Node customBlockNode({
  String? text,
  Map<String, dynamic>? attributes,
  List<Node>? children,
}) {
  return Node(
    type: CustomBlockKeys.type,
    attributes: {
      if (text != null) 'text': text,
      if (attributes != null) ...attributes,
    },
    children: children ?? [],
  );
}

3. Block Component Classes

Implement three classes:
  • Builder - Factory for creating widgets
  • Widget - Stateful widget for the block
  • State - Widget state with rendering logic

Example: Callout Block

Let’s create a callout block with an icon and colored background:

Step 1: Define Block Keys

class CalloutBlockKeys {
  const CalloutBlockKeys._();

  static const String type = 'callout';
  static const String delta = blockComponentDelta;
  static const String icon = 'icon';              // Emoji icon
  static const String backgroundColor = 'backgroundColor';
}

Step 2: Create Node Factory

Node calloutNode({
  String? text,
  Delta? delta,
  String icon = '💡',
  String backgroundColor = '0xFFFFF8DC',
  List<Node>? children,
}) {
  return Node(
    type: CalloutBlockKeys.type,
    attributes: {
      CalloutBlockKeys.delta:
          (delta ?? (Delta()..insert(text ?? ''))).toJson(),
      CalloutBlockKeys.icon: icon,
      CalloutBlockKeys.backgroundColor: backgroundColor,
    },
    children: children ?? [],
  );
}

Step 3: Create Builder

class CalloutBlockComponentBuilder extends BlockComponentBuilder {
  CalloutBlockComponentBuilder({
    super.configuration,
    this.defaultIcon = '💡',
    this.defaultBackgroundColor = const Color(0xFFFFF8DC),
  });

  final String defaultIcon;
  final Color defaultBackgroundColor;

  @override
  BlockComponentWidget build(BlockComponentContext blockComponentContext) {
    final node = blockComponentContext.node;

    return CalloutBlockComponentWidget(
      key: node.key,
      node: node,
      configuration: configuration,
      defaultIcon: defaultIcon,
      defaultBackgroundColor: defaultBackgroundColor,
      showActions: showActions(node),
      actionBuilder: (context, state) => actionBuilder(
        blockComponentContext,
        state,
      ),
    );
  }

  @override
  BlockComponentValidate get validate => (node) => node.delta != null;
}

Step 4: Create Widget

class CalloutBlockComponentWidget extends BlockComponentStatefulWidget {
  const CalloutBlockComponentWidget({
    super.key,
    required super.node,
    super.showActions,
    super.actionBuilder,
    super.configuration = const BlockComponentConfiguration(),
    this.defaultIcon = '💡',
    this.defaultBackgroundColor = const Color(0xFFFFF8DC),
  });

  final String defaultIcon;
  final Color defaultBackgroundColor;

  @override
  State<CalloutBlockComponentWidget> createState() =>
      _CalloutBlockComponentWidgetState();
}

Step 5: Implement State

class _CalloutBlockComponentWidgetState
    extends State<CalloutBlockComponentWidget>
    with
        SelectableMixin,
        DefaultSelectableMixin,
        BlockComponentConfigurable,
        NestedBlockComponentStatefulWidgetMixin {
  
  @override
  final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');

  @override
  GlobalKey<State<StatefulWidget>> get containerKey => widget.node.key;

  @override
  GlobalKey<State<StatefulWidget>> blockComponentKey = GlobalKey(
    debugLabel: CalloutBlockKeys.type,
  );

  @override
  BlockComponentConfiguration get configuration => widget.configuration;

  @override
  Node get node => widget.node;

  String get icon => 
      node.attributes[CalloutBlockKeys.icon] ?? widget.defaultIcon;

  Color get backgroundColor {
    final colorString = node.attributes[CalloutBlockKeys.backgroundColor];
    return colorString?.tryToColor() ?? widget.defaultBackgroundColor;
  }

  @override
  Widget buildComponent(
    BuildContext context, {
    bool withBackgroundColor = true,
  }) {
    return Container(
      key: blockComponentKey,
      decoration: BoxDecoration(
        color: backgroundColor,
        borderRadius: BorderRadius.circular(8.0),
        border: Border.all(
          color: backgroundColor.withOpacity(0.5),
          width: 2,
        ),
      ),
      padding: padding,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Icon
          Padding(
            padding: const EdgeInsets.only(right: 8.0, top: 2.0),
            child: Text(
              icon,
              style: const TextStyle(fontSize: 24),
            ),
          ),
          // Content
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                AppFlowyRichText(
                  key: forwardKey,
                  delegate: this,
                  node: node,
                  editorState: editorState,
                  placeholderText: placeholderText,
                  textSpanDecorator: (textSpan) =>
                      textSpan.updateTextStyle(
                    textStyleWithTextSpan(textSpan: textSpan),
                  ),
                  placeholderTextSpanDecorator: (textSpan) =>
                      textSpan.updateTextStyle(
                    placeholderTextStyleWithTextSpan(textSpan: textSpan),
                  ),
                  cursorColor: editorState.editorStyle.cursorColor,
                  selectionColor: editorState.editorStyle.selectionColor,
                ),
                // Nested children
                ...node.children.map(
                  (child) => editorState.renderer.build(context, child),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Step 6: Register in Editor

AppFlowyEditor(
  editorState: editorState,
  blockComponentBuilders: {
    CalloutBlockKeys.type: CalloutBlockComponentBuilder(
      configuration: BlockComponentConfiguration(
        padding: (node) => const EdgeInsets.all(16.0),
        placeholderText: (node) => 'Callout',
      ),
    ),
    // ... other blocks
  },
);

Essential Mixins

SelectableMixin

Provides basic selection functionality:
with SelectableMixin
Required Overrides:
  • start() - Starting position
  • end() - Ending position
  • getPositionInOffset() - Position from offset
  • getRectsInSelection() - Selection rectangles
  • getSelectionInRange() - Selection from range

DefaultSelectableMixin

Provides default implementations for text-based blocks:
with SelectableMixin, DefaultSelectableMixin
Automatically handles:
  • Text selection
  • Cursor positioning
  • Selection rectangles

BlockComponentConfigurable

Provides access to configuration:
with BlockComponentConfigurable
Provides:
  • padding - Block padding
  • textStyleWithTextSpan() - Text styling
  • placeholderText - Placeholder text
  • textAlign - Text alignment

NestedBlockComponentStatefulWidgetMixin

Enables nested children support:
with NestedBlockComponentStatefulWidgetMixin
Required for: Blocks with child nodes

BlockComponentBackgroundColorMixin

Adds background color support:
with BlockComponentBackgroundColorMixin
Provides:
  • backgroundColor property
  • decoration with background

BlockComponentTextDirectionMixin

Adds text direction support:
with BlockComponentTextDirectionMixin
Provides:
  • calculateTextDirection() method
  • LTR/RTL handling

BlockComponentAlignMixin

Adds alignment support:
with BlockComponentAlignMixin
Provides:
  • alignment property
  • Text alignment handling

Example: Code Block

Here’s a syntax-highlighted code block:
class CodeBlockKeys {
  static const String type = 'code';
  static const String delta = blockComponentDelta;
  static const String language = 'language';
}

Node codeBlockNode({
  String? code,
  String language = 'plaintext',
}) {
  return Node(
    type: CodeBlockKeys.type,
    attributes: {
      CodeBlockKeys.delta: (Delta()..insert(code ?? '')).toJson(),
      CodeBlockKeys.language: language,
    },
  );
}

class CodeBlockComponentBuilder extends BlockComponentBuilder {
  @override
  BlockComponentWidget build(BlockComponentContext blockComponentContext) {
    return CodeBlockComponentWidget(
      key: blockComponentContext.node.key,
      node: blockComponentContext.node,
      configuration: configuration,
    );
  }

  @override
  BlockComponentValidate get validate => (node) => node.delta != null;
}

class CodeBlockComponentWidget extends BlockComponentStatefulWidget {
  const CodeBlockComponentWidget({
    super.key,
    required super.node,
    super.configuration = const BlockComponentConfiguration(),
  });

  @override
  State<CodeBlockComponentWidget> createState() =>
      _CodeBlockComponentWidgetState();
}

class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
    with
        SelectableMixin,
        DefaultSelectableMixin,
        BlockComponentConfigurable {
  
  @override
  final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');

  @override
  GlobalKey<State<StatefulWidget>> get containerKey => widget.node.key;

  @override
  GlobalKey<State<StatefulWidget>> blockComponentKey = GlobalKey();

  @override
  BlockComponentConfiguration get configuration => widget.configuration;

  @override
  Node get node => widget.node;

  String get language =>
      node.attributes[CodeBlockKeys.language] ?? 'plaintext';

  @override
  Widget buildComponent(BuildContext context, {bool withBackgroundColor = true}) {
    return Container(
      key: blockComponentKey,
      decoration: BoxDecoration(
        color: Colors.grey[900],
        borderRadius: BorderRadius.circular(8.0),
      ),
      padding: const EdgeInsets.all(16.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Language selector
          Container(
            padding: const EdgeInsets.symmetric(
              horizontal: 8.0,
              vertical: 4.0,
            ),
            decoration: BoxDecoration(
              color: Colors.grey[800],
              borderRadius: BorderRadius.circular(4.0),
            ),
            child: Text(
              language,
              style: const TextStyle(
                color: Colors.white70,
                fontSize: 12,
              ),
            ),
          ),
          const SizedBox(height: 8),
          // Code content
          AppFlowyRichText(
            key: forwardKey,
            delegate: this,
            node: node,
            editorState: editorState,
            textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
              const TextStyle(
                fontFamily: 'monospace',
                color: Colors.white,
                fontSize: 14,
              ),
            ),
            cursorColor: Colors.white,
            selectionColor: Colors.blue.withOpacity(0.3),
          ),
        ],
      ),
    );
  }
}

Example: Toggle/Disclosure Block

A collapsible block with show/hide functionality:
class ToggleBlockKeys {
  static const String type = 'toggle';
  static const String delta = blockComponentDelta;
  static const String collapsed = 'collapsed';
}

Node toggleBlockNode({
  String? text,
  bool collapsed = false,
  List<Node>? children,
}) {
  return Node(
    type: ToggleBlockKeys.type,
    attributes: {
      ToggleBlockKeys.delta: (Delta()..insert(text ?? '')).toJson(),
      ToggleBlockKeys.collapsed: collapsed,
    },
    children: children ?? [],
  );
}

class _ToggleBlockComponentWidgetState extends State<ToggleBlockComponentWidget>
    with
        SelectableMixin,
        DefaultSelectableMixin,
        BlockComponentConfigurable,
        NestedBlockComponentStatefulWidgetMixin {
  
  // ... standard setup ...

  bool get isCollapsed =>
      node.attributes[ToggleBlockKeys.collapsed] ?? false;

  void toggleCollapsed() {
    final transaction = editorState.transaction;
    transaction.updateNode(node, {
      ToggleBlockKeys.collapsed: !isCollapsed,
    });
    editorState.apply(transaction);
  }

  @override
  Widget buildComponent(BuildContext context, {bool withBackgroundColor = true}) {
    return Container(
      key: blockComponentKey,
      padding: padding,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              // Toggle icon
              GestureDetector(
                onTap: toggleCollapsed,
                child: Icon(
                  isCollapsed
                      ? Icons.arrow_right
                      : Icons.arrow_drop_down,
                  size: 24,
                ),
              ),
              const SizedBox(width: 4),
              // Content
              Expanded(
                child: AppFlowyRichText(
                  key: forwardKey,
                  delegate: this,
                  node: node,
                  editorState: editorState,
                  // ... configuration ...
                ),
              ),
            ],
          ),
          // Nested content (collapsible)
          if (!isCollapsed)
            Padding(
              padding: const EdgeInsets.only(left: 24.0),
              child: Column(
                children: node.children.map(
                  (child) => editorState.renderer.build(context, child),
                ).toList(),
              ),
            ),
        ],
      ),
    );
  }
}

Non-Editable Blocks

For blocks without text content (like images or dividers):
class _CustomBlockWidgetState extends State<CustomBlockWidget>
    with SelectableMixin, BlockComponentConfigurable {
  
  @override
  Position start() => Position(path: widget.node.path, offset: 0);

  @override
  Position end() => Position(path: widget.node.path, offset: 1);

  @override
  Position getPositionInOffset(Offset start) => end();

  @override
  bool get shouldCursorBlink => false;

  @override
  CursorStyle get cursorStyle => CursorStyle.cover;

  @override
  List<Rect> getRectsInSelection(Selection selection, {
    bool shiftWithBaseOffset = false,
  }) {
    // Return block rectangle
    final renderBox = context.findRenderObject() as RenderBox?;
    if (renderBox == null) return [];
    return [Offset.zero & renderBox.size];
  }

  @override
  Selection getSelectionInRange(Offset start, Offset end) =>
      Selection.single(
        path: widget.node.path,
        startOffset: 0,
        endOffset: 1,
      );

  // ... other required overrides ...
}

Block Validation

Implement validation to ensure block integrity:
@override
BlockComponentValidate get validate => (node) {
  // Check required attributes
  if (!node.attributes.containsKey('requiredAttribute')) {
    return false;
  }
  
  // Check node structure
  if (node.children.isEmpty) {
    return false;
  }
  
  // Validate attribute values
  final value = node.attributes['someValue'];
  if (value != null && value is! String) {
    return false;
  }
  
  return true;
};

Action Builders

Add custom actions (drag handles, menus):
class CustomBlockComponentBuilder extends BlockComponentBuilder {
  @override
  BlockComponentWidget build(BlockComponentContext blockComponentContext) {
    return CustomBlockComponentWidget(
      // ...
      showActions: true,
      actionBuilder: (context, state) {
        return Row(
          children: [
            IconButton(
              icon: const Icon(Icons.drag_indicator),
              onPressed: () => handleDrag(state.node),
            ),
            IconButton(
              icon: const Icon(Icons.more_vert),
              onPressed: () => showMenu(context, state.node),
            ),
          ],
        );
      },
    );
  }
}

Testing Custom Blocks

void testCustomBlock() {
  final editorState = EditorState(
    document: Document.blank(),
  );

  // Add custom block
  final transaction = editorState.transaction;
  transaction.insertNode(
    [0],
    customBlockNode(text: 'Test content'),
  );
  editorState.apply(transaction);

  // Verify
  final node = editorState.getNodeAtPath([0]);
  expect(node?.type, equals(CustomBlockKeys.type));
  expect(node?.delta?.toPlainText(), equals('Test content'));
}

Best Practices

  1. Use appropriate mixins - Only include what you need
  2. Validate thoroughly - Ensure block structure is valid
  3. Handle edge cases - Empty states, invalid data
  4. Provide defaults - Sensible default values
  5. Document attributes - Clear documentation for attributes
  6. Test extensively - Unit and integration tests
  7. Consider accessibility - Keyboard navigation, screen readers
  8. Performance - Avoid expensive operations in build
  9. State management - Keep state minimal and efficient
  10. Consistent styling - Match editor’s design system

Common Patterns

Text-Based Blocks

  • Use DefaultSelectableMixin
  • Include AppFlowyRichText widget
  • Support Delta format

Container Blocks

  • Use NestedBlockComponentStatefulWidgetMixin
  • Render children with editorState.renderer.build()
  • Support nesting

Visual Blocks

  • Implement custom SelectableMixin methods
  • Use CursorStyle.cover for non-text blocks
  • Return proper rectangles for selection

Interactive Blocks

  • Add gesture detectors
  • Update state through transactions
  • Handle user interactions properly

Block Component Overview

Learn about the block system

Standard Blocks

Explore built-in block types

Build docs developers (and LLMs) love