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:
Built-in Extensions
Node Flow includes these built-in extensions:
| Extension | Purpose | Default State | Access |
|---|---|---|---|
| AutoPanExtension | Pan viewport when dragging near edges | Enabled | controller.autoPan |
| MinimapExtension | Navigate overview panel | Visible | controller.minimap |
| LodExtension | Detail visibility based on zoom | Disabled | controller.lod |
| DebugExtension | Debug overlays | Disabled | controller.debug |
| StatsExtension | Graph statistics | Enabled | controller.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/Method | Description |
|---|---|
id | Unique 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
- Keep extensions focused: Each extension should do one thing well
- Use MobX for state: Makes extension state reactive with UI
- Handle detach properly: Clean up timers, listeners, subscriptions
- Provide typed accessors: Add extension methods for ergonomic access
- Use pattern matching: Handle only the events you care about
- Consider batching: Use
BatchStarted/BatchEndedfor grouping related changes