Skip to content

Extensions

Node Flow uses an extension system to add features without bloating the core. Extensions are self-contained modules that observe events, manage their own state, and add capabilities like minimap, autopan, and debug visualization.

How Extensions Work

Extensions follow a simple lifecycle:

Extension Lifecycle

Built-in Extensions

Node Flow includes these built-in extensions:

ExtensionPurposeDefault StateAccess
AutoPanExtensionPan viewport when dragging near edgesEnabledcontroller.autoPan
MinimapExtensionNavigate overview panelVisiblecontroller.minimap
LodExtensionDetail visibility based on zoomDisabledcontroller.lod
DebugExtensionDebug overlaysDisabledcontroller.debug
StatsExtensionGraph statisticsEnabledcontroller.stats

Default Extensions

When no extensions are specified, Node Flow includes a default set:

dart
// These are added automatically if extensions: is null
final defaultExtensions = [
  AutoPanExtension(),      // Enabled by default
  DebugExtension(),        // Disabled by default (mode: none)
  LodExtension(),          // Disabled by default
  MinimapExtension(),      // Visible by default
  StatsExtension(),        // Always available
];

Custom Extension List

Override the defaults by providing your own list:

dart
NodeFlowConfig(
  extensions: [
    // Only include what you need
    MinimapExtension(visible: true),
    AutoPanExtension(),
    // No debug, no LOD, no stats
  ],
)

Accessing Extensions

Extensions are accessed via typed getters on the controller:

dart
// Each built-in extension has a typed getter
controller.minimap?.toggle();
controller.autoPan?.useFast();
controller.lod?.enable();
controller.debug?.setMode(DebugMode.all);
controller.stats?.nodeCount;

// All getters return nullable types (null if not registered)
if (controller.minimap != null) {
  // Extension is available
}

Resolving Custom Extensions

For custom extensions, use resolveExtension<T>():

dart
// Get a custom extension by type
final myExtension = controller.resolveExtension<MyCustomExtension>();

// Or create a typed extension getter
extension MyExtensionAccess<T> on NodeFlowController<T, dynamic> {
  MyCustomExtension? get myExtension =>
      resolveExtension<MyCustomExtension>();
}

// Then use it like built-in extensions
controller.myExtension?.doSomething();

Creating Custom Extensions

Extensions implement the NodeFlowExtension interface:

dart
import 'package:vyuh_node_flow/vyuh_node_flow.dart';

class LoggingExtension extends NodeFlowExtension {
  NodeFlowController? _controller;

  @override
  String get id => 'logging';

  @override
  void attach(NodeFlowController controller) {
    _controller = controller;
    print('Logging extension attached');
    print('Graph has ${controller.nodes.length} nodes');
  }

  @override
  void detach() {
    print('Logging extension detached');
    _controller = null;
  }

  @override
  void onEvent(GraphEvent event) {
    switch (event) {
      case NodeAdded(:final node):
        print('Node added: ${node.id}');
      case NodeMoved(:final node, :final previousPosition):
        print('Node ${node.id} moved from $previousPosition');
      case ConnectionCreated(:final connection):
        print('Connection: ${connection.sourceNodeId}${connection.targetNodeId}');
      default:
        // Ignore other events
    }
  }
}

Extension Properties

Property/MethodDescription
idUnique identifier (prevents duplicates)
attach(controller)Called when registered; store the controller reference
detach()Called when unregistered; clean up resources
onEvent(event)Called for each graph event

Event Types

Extensions receive all graph events:

dart
@override
void onEvent(GraphEvent event) {
  switch (event) {
    // Node events
    case NodeAdded(:final node): ...
    case NodeRemoved(:final node): ...
    case NodeMoved(:final node, :final previousPosition): ...
    case NodeResized(:final node, :final previousSize): ...
    case NodeDataChanged(:final node, :final previousData): ...
    case NodeSelected(:final nodeIds): ...
    case NodeDeselected(:final nodeIds): ...

    // Connection events
    case ConnectionCreated(:final connection): ...
    case ConnectionRemoved(:final connection): ...
    case ConnectionSelected(:final connectionIds): ...
    case ConnectionDeselected(:final connectionIds): ...

    // Viewport events
    case ViewportChanged(:final viewport): ...

    // Batch events (for undo/redo grouping)
    case BatchStarted(:final reason): ...
    case BatchEnded(): ...

    default: break;
  }
}

Stateful Extensions

Most extensions maintain observable state using MobX:

dart
class SelectionTrackerExtension extends NodeFlowExtension {
  NodeFlowController? _controller;

  // Observable state
  final Observable<int> _selectionCount = Observable(0);
  final Observable<DateTime?> _lastSelectionTime = Observable(null);

  // Public accessors
  int get selectionCount => _selectionCount.value;
  DateTime? get lastSelectionTime => _lastSelectionTime.value;

  @override
  String get id => 'selection-tracker';

  @override
  void attach(NodeFlowController controller) {
    _controller = controller;
  }

  @override
  void detach() {
    _controller = null;
  }

  @override
  void onEvent(GraphEvent event) {
    switch (event) {
      case NodeSelected(:final nodeIds):
        runInAction(() {
          _selectionCount.value += nodeIds.length;
          _lastSelectionTime.value = DateTime.now();
        });
      case NodeDeselected(:final nodeIds):
        runInAction(() {
          _selectionCount.value -= nodeIds.length;
        });
      default:
        break;
    }
  }
}

Using Stateful Extensions in UI

dart
Observer(
  builder: (_) {
    final tracker = controller.resolveExtension<SelectionTrackerExtension>();
    if (tracker == null) return const SizedBox.shrink();

    return Text('${tracker.selectionCount} items selected');
  },
)

Extension Patterns

Undo/Redo Extension

Extensions are ideal for implementing undo/redo:

dart
class UndoRedoExtension extends NodeFlowExtension {
  final List<GraphEvent> _undoStack = [];
  final List<GraphEvent> _redoStack = [];
  NodeFlowController? _controller;

  bool get canUndo => _undoStack.isNotEmpty;
  bool get canRedo => _redoStack.isNotEmpty;

  @override
  String get id => 'undo-redo';

  @override
  void attach(NodeFlowController controller) {
    _controller = controller;
  }

  @override
  void detach() {
    _controller = null;
  }

  @override
  void onEvent(GraphEvent event) {
    // Only track undoable events
    switch (event) {
      case NodeMoved():
      case NodeResized():
      case ConnectionCreated():
      case ConnectionRemoved():
        _undoStack.add(event);
        _redoStack.clear(); // Clear redo on new action
      default:
        break;
    }
  }

  void undo() {
    if (!canUndo) return;
    final event = _undoStack.removeLast();
    _redoStack.add(event);
    _applyInverse(event);
  }

  void redo() {
    if (!canRedo) return;
    final event = _redoStack.removeLast();
    _undoStack.add(event);
    _applyEvent(event);
  }

  void _applyInverse(GraphEvent event) {
    // Implement inverse operations
  }

  void _applyEvent(GraphEvent event) {
    // Implement forward operations
  }
}

Auto-Save Extension

dart
class AutoSaveExtension extends NodeFlowExtension {
  final Duration debounceTime;
  final void Function(Map<String, dynamic>) onSave;

  Timer? _debounceTimer;
  NodeFlowController? _controller;

  AutoSaveExtension({
    this.debounceTime = const Duration(seconds: 2),
    required this.onSave,
  });

  @override
  String get id => 'auto-save';

  @override
  void attach(NodeFlowController controller) {
    _controller = controller;
  }

  @override
  void detach() {
    _debounceTimer?.cancel();
    _controller = null;
  }

  @override
  void onEvent(GraphEvent event) {
    // Debounce save on any data change
    switch (event) {
      case NodeAdded():
      case NodeRemoved():
      case NodeMoved():
      case ConnectionCreated():
      case ConnectionRemoved():
        _scheduleSave();
      default:
        break;
    }
  }

  void _scheduleSave() {
    _debounceTimer?.cancel();
    _debounceTimer = Timer(debounceTime, () {
      if (_controller != null) {
        onSave(_controller!.toJson());
      }
    });
  }
}

Best Practices

  1. Keep extensions focused: Each extension should do one thing well
  2. Use MobX for state: Makes extension state reactive with UI
  3. Handle detach properly: Clean up timers, listeners, subscriptions
  4. Provide typed accessors: Add extension methods for ergonomic access
  5. Use pattern matching: Handle only the events you care about
  6. Consider batching: Use BatchStarted/BatchEnded for grouping related changes

See Also