Skip to main content
AppFlowy Editor uses a tree-based document structure where each element is represented as a Node. The Document class serves as the container for the root node and provides methods for querying and manipulating the document tree.

Document Class

The Document represents the entire editor document structure and stores the root node.

Creating a Document

// From JSON
final document = Document.fromJson({
  'document': {
    'type': 'page',
    'children': [
      {
        'type': 'paragraph',
        'data': {
          'delta': [
            {'insert': 'Hello, world!'}
          ]
        }
      }
    ]
  }
});

// Blank document
final document = Document.blank(withInitialText: true);

Core Properties

class Document {
  // The root node of the document
  final Node root;
  
  // First node of the document
  Node? get first => root.children.firstOrNull;
  
  // Last node of the document
  Node? get last {
    Node? current = root.children.lastOrNull;
    while (current != null && current.children.isNotEmpty) {
      current = current.children.last;
    }
    return current;
  }
  
  // Check if document is empty
  bool get isEmpty;
}

Node Class

A Node represents a single element in the document tree. Each node has a type, attributes, and can contain child nodes.

Node Structure

class Node {
  // The type determines which block component renders this node
  final String type;
  
  // Unique identifier (auto-generated if not provided)
  String id;
  
  // Parent node reference
  Node? parent;
  
  // Child nodes
  List<Node> get children;
  
  // Node attributes/data
  Attributes get attributes;
  
  // Path from root to this node
  Path get path;
}

JSON Format

Nodes serialize to a simple JSON structure:
{
  "type": "paragraph",
  "data": {
    "delta": [
      {"insert": "Hello "},
      {"insert": "world", "attributes": {"bold": true}}
    ]
  },
  "children": []
}

Creating Nodes

Programmatically

// Simple node
final node = Node(
  type: 'paragraph',
  attributes: {
    'delta': [
      {'insert': 'Hello, world!'}
    ]
  },
);

// Node with children
final parent = Node(
  type: 'bulleted_list',
  children: [
    Node(type: 'list_item', attributes: {'delta': [{'insert': 'Item 1'}]}),
    Node(type: 'list_item', attributes: {'delta': [{'insert': 'Item 2'}]}),
  ],
);

From JSON

final node = Node.fromJson({
  'type': 'heading',
  'data': {
    'level': 1,
    'delta': [{'insert': 'Title'}]
  },
});

Node Types

The type property determines how a node is rendered. Common built-in types include:
  • paragraph - Basic text paragraph
  • heading - Headings (levels 1-6)
  • bulleted_list - Bulleted list container
  • numbered_list - Numbered list container
  • todo_list - Checkbox list items
  • quote - Block quote
  • code - Code block
  • image - Image block
  • divider - Horizontal divider
  • page - Root page node
You can define custom node types by creating custom block components. See the Custom Blocks guide.

Node Attributes

Attributes is a type alias for Map<String, dynamic> and stores node-specific data.

Common Attributes

// Text content (Delta format)
'delta': [{'insert': 'text'}]

// Heading level
'level': 1  // 1-6

// Text alignment
'align': 'left'  // left, center, right

// Background color
'backgroundColor': '0xFF000000'

// Custom attributes
'customKey': 'customValue'

Updating Attributes

Never modify attributes directly. Use transactions to ensure proper state management.
// ❌ Don't do this
node.attributes['delta'] = newDelta;

// ✅ Do this instead
final transaction = editorState.transaction;
transaction.updateNode(node, {'delta': newDelta});
await editorState.apply(transaction);

Paths

A Path is a list of integers representing the route from the root node to a specific node.
typedef Path = List<int>;

Path Examples

Root (page)
├─ [0] Paragraph "Hello"
├─ [1] Bulleted List
│  ├─ [1, 0] List Item "First"
│  └─ [1, 1] List Item "Second"
└─ [2] Paragraph "World"

Path Operations

// Get next sibling path
final nextPath = path.next;  // [0] -> [1]

// Get previous sibling path
final prevPath = path.previous;  // [1] -> [0]

// Get parent path
final parentPath = path.parent;  // [1, 0] -> [1]

// Get child path
final childPath = path.child(0);  // [1] -> [1, 0]

// Compare paths
if (path1 < path2) { /* ... */ }
if (path1.equals(path2)) { /* ... */ }

// Check if path is ancestor
if (path.isAncestorOf(otherPath)) { /* ... */ }

Document Operations

The Document class provides low-level operations. However, you should typically use transactions instead.

Query Operations

// Get node at path
final node = document.nodeAtPath([0, 1]);

// Get first node
final first = document.first;

// Get last node
final last = document.last;

// Check if empty
if (document.isEmpty) {
  // Handle empty document
}

Mutation Operations (Use Transactions Instead)

// These methods exist but should be called via transactions
document.insert(path, nodes);      // ❌ Direct use not recommended
document.delete(path, length);     // ❌ Direct use not recommended
document.update(path, attributes); // ❌ Direct use not recommended
Always use transactions to modify the document. Direct mutation bypasses undo/redo and collaborative editing support.

Working with Children

Accessing Children

// Get all children
final children = node.children;

// Get child at index
final child = node.childAtIndexOrNull(0);

// Get child at path
final descendant = node.childAtPath([0, 1]);

Inserting Children (via Transaction)

final transaction = editorState.transaction;

// Insert single node
transaction.insertNode([1], newNode);

// Insert multiple nodes
transaction.insertNodes([1], [node1, node2]);

await editorState.apply(transaction);

Node Hierarchy Example

Here’s a complete example showing a typical document structure:
final document = Document(
  root: Node(
    type: 'page',
    children: [
      // Heading
      Node(
        type: 'heading',
        attributes: {
          'level': 1,
          'delta': [{'insert': 'Document Title'}],
        },
      ),
      // Paragraph
      Node(
        type: 'paragraph',
        attributes: {
          'delta': [
            {'insert': 'This is a '},
            {'insert': 'bold', 'attributes': {'bold': true}},
            {'insert': ' paragraph.'},
          ],
        },
      ),
      // Bulleted list with items
      Node(
        type: 'bulleted_list',
        children: [
          Node(
            type: 'list_item',
            attributes: {
              'delta': [{'insert': 'First item'}],
            },
          ),
          Node(
            type: 'list_item',
            attributes: {
              'delta': [{'insert': 'Second item'}],
            },
            children: [
              Node(
                type: 'list_item',
                attributes: {
                  'delta': [{'insert': 'Nested item'}],
                },
              ),
            ],
          ),
        ],
      ),
    ],
  ),
);

Deep Copying Nodes

// Create a deep copy of a node
final copy = node.copyWith();

// Copy with modifications
final modified = node.copyWith(
  type: 'heading',
  attributes: {'level': 2},
);

// Deep copy (same as copyWith with no params)
final deepCopy = node.deepCopy();

Converting to JSON

// Convert document to JSON
final json = document.toJson();
// Returns: {'document': {...}}

// Convert node to JSON
final nodeJson = node.toJson();
// Returns: {'type': '...', 'data': {...}, 'children': [...]}

Lifecycle

Disposal

Documents should be disposed when no longer needed:
document.dispose();
This recursively disposes all nodes in the document tree.
When using EditorState, the document is automatically disposed when editorState.dispose() is called.

Best Practices

Use Transactions

Always modify the document through transactions, never directly.

Immutable Paths

Paths can change after document mutations. Don’t cache paths long-term.

Type Safety

Validate node types before accessing type-specific attributes.

Proper Disposal

Always dispose documents to prevent memory leaks.

See Also

Build docs developers (and LLMs) love