feat(editor): empty-graph CTA + fit-to-screen button

Two polishing tweaks on the graph tab:

 - When the active flow has no steps, the canvas would have
   rendered as a near-empty grid (just inputs / outputs
   sidebars). Overlay a centred call-to-action with the
   graph icon, the "No steps yet" / "Noch keine Schritte"
   heading, a one-liner explainer, and a primary Add Step
   button that opens the capability picker directly.
 - Auto-fit zoom on first open of each flow + a floating
   "Fit to screen" button (bottom-right corner of the
   canvas viewport). The auto-fit re-runs only when the
   flow name changes, so the operator's manual pan / zoom
   on the current flow is preserved. The fit math expands
   the bounding box to include inputs + outputs pseudo-
   nodes too, so wide flows zoom out enough to show every
   port at once.

Fit math:
 - Walk every step's stored position + height; merge with
   the inputs sidebar's known position.
 - Compute scale that makes the bounding box fit the
   viewport with 80 px padding on each side.
 - Clamp to [0.4, 2.0] — InteractiveViewer's own bounds.
 - Translate so the box centres in the viewport.

`flutter analyze` clean, all 11 tests still pass.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 01:51:11 +02:00
parent 6b70ba65fd
commit 9d21bd5318
2 changed files with 220 additions and 81 deletions

View file

@ -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<FlowCanvas> {
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<FlowCanvas> {
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 _) {