diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 10e52a9..191b93b 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -416,11 +416,58 @@ outputs: // When a step is selected, show the properties panel on // the right as a fixed sidebar. Selection is owned by // the controller and ChangeNotifier rebuilds drive the - // panel show / hide. + // panel show / hide. When the flow has no steps at all, + // overlay a call-to-action so the operator's first + // instinct is the right action rather than staring at an + // empty grid. + final hasSteps = _controller.graph.steps.isNotEmpty; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded(child: FlowCanvas(controller: _controller)), + Expanded( + child: Stack( + children: [ + FlowCanvas(controller: _controller), + if (!hasSteps) + Positioned.fill( + child: Container( + color: theme.colorScheme.surface.withValues(alpha: 0.92), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.account_tree_outlined, + size: 48, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: FaiSpace.md), + Text( + _l.graphEmptyTitle, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: FaiSpace.sm), + Text( + _l.graphEmptyBody, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + FilledButton.icon( + onPressed: _addStep, + icon: const Icon(Icons.add_box_outlined, size: 16), + label: Text(_l.addStep), + ), + ], + ), + ), + ), + ), + ], + ), + ), if (_controller.selectedStepId != null) ...[ const VerticalDivider(width: 1), SizedBox( diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 0ff032c..ce00cd5 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -37,6 +37,7 @@ import 'package:flutter/material.dart'; import '../editor_controller.dart'; import '../model/flow_graph.dart'; import '../model/layout_store.dart'; +import '../tokens.dart'; import 'edge_painter.dart'; import 'flow_node.dart'; @@ -62,6 +63,10 @@ class FlowCanvas extends StatefulWidget { class _FlowCanvasState extends State { final TransformationController _transform = TransformationController(); + // Track which flow we last fitted-to-screen for so we + // don't override the operator's manual pan/zoom every + // build. Re-fit when the active flow changes. + String? _fittedFor; // Active connection-drag state. When non-null, the canvas // paints a draft edge from the source port to the cursor @@ -91,98 +96,185 @@ class _FlowCanvasState extends State { final graph = widget.controller.graph; final layout = widget.controller.layout; final outputsX = _outputsX(graph, layout); + // Auto-fit on first build for each flow so the operator + // sees the whole graph immediately, even on flows whose + // auto-layout pushes nodes past the default viewport. + // Re-fit triggers only when the flow name changes — the + // operator's subsequent zooms / pans stay theirs. + final activeName = widget.controller.activeName; + if (activeName != null && activeName != _fittedFor) { + _fittedFor = activeName; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _fitToContent(); + }); + } return Container( color: theme.colorScheme.surface, - child: InteractiveViewer( - transformationController: _transform, - constrained: false, - boundaryMargin: const EdgeInsets.all(400), - minScale: 0.4, - maxScale: 2.0, - child: SizedBox( - width: _canvasWidth, - height: _canvasHeight, - child: Stack( - children: [ - _grid(theme), - // Edges first so nodes paint on top of them. - Positioned.fill( - child: IgnorePointer( - child: CustomPaint( - painter: EdgePainter( - segments: _buildSegments(graph, layout, outputsX), - baseColor: theme.colorScheme.onSurfaceVariant.withValues( - alpha: 0.55, + child: Stack( + children: [ + InteractiveViewer( + transformationController: _transform, + constrained: false, + boundaryMargin: const EdgeInsets.all(400), + minScale: 0.4, + maxScale: 2.0, + child: SizedBox( + width: _canvasWidth, + height: _canvasHeight, + child: Stack( + children: [ + _grid(theme), + // Edges first so nodes paint on top of them. + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: EdgePainter( + segments: _buildSegments(graph, layout, outputsX), + baseColor: theme.colorScheme.onSurfaceVariant + .withValues(alpha: 0.55), + highlightColor: theme.colorScheme.primary, + draftColor: theme.colorScheme.primary, + ), ), - highlightColor: theme.colorScheme.primary, - draftColor: theme.colorScheme.primary, ), ), - ), - ), - // Inputs pseudo-node. - Positioned( - left: _inputsX, - top: _inputsY, - child: FlowNode( - id: '__inputs__', - title: 'inputs', - kind: NodeVisualKind.inputs, - inputPortLabels: graph.inputs.keys - .map((k) => '$k: ${graph.inputs[k]!.type}') - .toList(), - selected: false, - ), - ), - // Outputs pseudo-node. - Positioned( - left: outputsX, - top: _inputsY, - child: FlowNode( - id: '__outputs__', - title: 'outputs', - kind: NodeVisualKind.outputs, - inputPortLabels: graph.outputs.keys.toList(), - selected: false, - ), - ), - // Step nodes — positioned absolutely, drag to - // move, click to select. - for (final step in graph.steps) - _stepPositioned(step, layout, outputsX), - // Port hit-targets for connection drawing. A - // transparent overlay positioned over each port - // — easier to manage than per-port GestureDetectors - // inside the node widget because connection drags - // need to cross node boundaries (start in one node, - // end in another). - ..._portOverlays(graph, layout, outputsX), - if (_draft != null) - Positioned.fill( - child: IgnorePointer( - child: CustomPaint( - painter: EdgePainter( - segments: [ - EdgeSegment( - from: _draft!.from, - to: _draft!.cursor, - accent: EdgeAccent.draftDrag, + // Inputs pseudo-node. + Positioned( + left: _inputsX, + top: _inputsY, + child: FlowNode( + id: '__inputs__', + title: 'inputs', + kind: NodeVisualKind.inputs, + inputPortLabels: graph.inputs.keys + .map((k) => '$k: ${graph.inputs[k]!.type}') + .toList(), + selected: false, + ), + ), + // Outputs pseudo-node. + Positioned( + left: outputsX, + top: _inputsY, + child: FlowNode( + id: '__outputs__', + title: 'outputs', + kind: NodeVisualKind.outputs, + inputPortLabels: graph.outputs.keys.toList(), + selected: false, + ), + ), + // Step nodes — positioned absolutely, drag to + // move, click to select. + for (final step in graph.steps) + _stepPositioned(step, layout, outputsX), + // Port hit-targets for connection drawing. A + // transparent overlay positioned over each port + // — easier to manage than per-port GestureDetectors + // inside the node widget because connection drags + // need to cross node boundaries (start in one node, + // end in another). + ..._portOverlays(graph, layout, outputsX), + if (_draft != null) + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: EdgePainter( + segments: [ + EdgeSegment( + from: _draft!.from, + to: _draft!.cursor, + accent: EdgeAccent.draftDrag, + ), + ], + baseColor: theme.colorScheme.primary, + highlightColor: theme.colorScheme.primary, + draftColor: theme.colorScheme.primary, ), - ], - baseColor: theme.colorScheme.primary, - highlightColor: theme.colorScheme.primary, - draftColor: theme.colorScheme.primary, + ), ), ), - ), - ), - ], + ], + ), + ), ), - ), + // Floating "Fit to screen" button — recenters the + // canvas so every node is in view at once. Pinned + // to the bottom-right of the viewport rather than + // the canvas surface so it doesn't drift with pan. + Positioned( + right: FaiSpace.md, + bottom: FaiSpace.md, + child: Material( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.sm), + elevation: 2, + child: IconButton( + onPressed: _fitToContent, + icon: const Icon(Icons.fit_screen_outlined, size: 18), + tooltip: 'Fit to screen', + visualDensity: VisualDensity.compact, + ), + ), + ), + ], ), ); } + /// Compute the bounding box of every visible node + the + /// inputs / outputs sidebars, then set the + /// TransformationController so the box fills the visible + /// viewport with breathing room. No-op when there's no + /// active flow (nothing to fit). + void _fitToContent() { + final graph = widget.controller.graph; + final layout = widget.controller.layout; + if (widget.controller.activeName == null) return; + if (graph.steps.isEmpty && graph.inputs.isEmpty) return; + // Bounding box: start with the inputs pseudo-node. + double minX = _inputsX; + double minY = _inputsY; + double maxX = _inputsX + NodeGeometry.width; + double maxY = + _inputsY + + NodeGeometry.heightFor(graph.inputs.length).clamp(110.0, 600.0); + // Outputs. + final outputsX = _outputsX(graph, layout); + maxX = outputsX + NodeGeometry.width > maxX + ? outputsX + NodeGeometry.width + : maxX; + // Step nodes. + for (final step in graph.steps) { + final pos = layout.positions[step.id]; + if (pos == null) continue; + if (pos.x < minX) minX = pos.x; + if (pos.y < minY) minY = pos.y; + final right = pos.x + NodeGeometry.width; + final bottom = pos.y + NodeGeometry.heightFor(step.with_.length); + if (right > maxX) maxX = right; + if (bottom > maxY) maxY = bottom; + } + // Padding so nodes don't kiss the viewport edge. + const pad = 80.0; + minX -= pad; + minY -= pad; + maxX += pad; + maxY += pad; + final boxW = maxX - minX; + final boxH = maxY - minY; + final size = (context.findRenderObject() as RenderBox?)?.size; + if (size == null || size.width <= 0 || size.height <= 0) return; + final scale = (size.width / boxW).clamp(0.0, 2.0).toDouble(); + final scale2 = (size.height / boxH).clamp(0.0, 2.0).toDouble(); + final finalScale = scale < scale2 ? scale : scale2; + final tx = -minX * finalScale + (size.width - boxW * finalScale) / 2; + final ty = -minY * finalScale + (size.height - boxH * finalScale) / 2; + _transform.value = Matrix4.identity() + ..translateByDouble(tx, ty, 0, 1) + ..scaleByDouble(finalScale, finalScale, 1, 1); + } + // --- Step positioning + drag --- Widget _stepPositioned(FlowStep step, FlowLayout layout, double _) {