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:
parent
6b70ba65fd
commit
9d21bd5318
2 changed files with 220 additions and 81 deletions
|
|
@ -416,11 +416,58 @@ outputs:
|
||||||
// When a step is selected, show the properties panel on
|
// When a step is selected, show the properties panel on
|
||||||
// the right as a fixed sidebar. Selection is owned by
|
// the right as a fixed sidebar. Selection is owned by
|
||||||
// the controller and ChangeNotifier rebuilds drive the
|
// 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(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
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) ...[
|
if (_controller.selectedStepId != null) ...[
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../editor_controller.dart';
|
import '../editor_controller.dart';
|
||||||
import '../model/flow_graph.dart';
|
import '../model/flow_graph.dart';
|
||||||
import '../model/layout_store.dart';
|
import '../model/layout_store.dart';
|
||||||
|
import '../tokens.dart';
|
||||||
import 'edge_painter.dart';
|
import 'edge_painter.dart';
|
||||||
import 'flow_node.dart';
|
import 'flow_node.dart';
|
||||||
|
|
||||||
|
|
@ -62,6 +63,10 @@ class FlowCanvas extends StatefulWidget {
|
||||||
|
|
||||||
class _FlowCanvasState extends State<FlowCanvas> {
|
class _FlowCanvasState extends State<FlowCanvas> {
|
||||||
final TransformationController _transform = TransformationController();
|
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
|
// Active connection-drag state. When non-null, the canvas
|
||||||
// paints a draft edge from the source port to the cursor
|
// 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 graph = widget.controller.graph;
|
||||||
final layout = widget.controller.layout;
|
final layout = widget.controller.layout;
|
||||||
final outputsX = _outputsX(graph, 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(
|
return Container(
|
||||||
color: theme.colorScheme.surface,
|
color: theme.colorScheme.surface,
|
||||||
child: InteractiveViewer(
|
child: Stack(
|
||||||
transformationController: _transform,
|
children: [
|
||||||
constrained: false,
|
InteractiveViewer(
|
||||||
boundaryMargin: const EdgeInsets.all(400),
|
transformationController: _transform,
|
||||||
minScale: 0.4,
|
constrained: false,
|
||||||
maxScale: 2.0,
|
boundaryMargin: const EdgeInsets.all(400),
|
||||||
child: SizedBox(
|
minScale: 0.4,
|
||||||
width: _canvasWidth,
|
maxScale: 2.0,
|
||||||
height: _canvasHeight,
|
child: SizedBox(
|
||||||
child: Stack(
|
width: _canvasWidth,
|
||||||
children: [
|
height: _canvasHeight,
|
||||||
_grid(theme),
|
child: Stack(
|
||||||
// Edges first so nodes paint on top of them.
|
children: [
|
||||||
Positioned.fill(
|
_grid(theme),
|
||||||
child: IgnorePointer(
|
// Edges first so nodes paint on top of them.
|
||||||
child: CustomPaint(
|
Positioned.fill(
|
||||||
painter: EdgePainter(
|
child: IgnorePointer(
|
||||||
segments: _buildSegments(graph, layout, outputsX),
|
child: CustomPaint(
|
||||||
baseColor: theme.colorScheme.onSurfaceVariant.withValues(
|
painter: EdgePainter(
|
||||||
alpha: 0.55,
|
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(
|
||||||
// Inputs pseudo-node.
|
left: _inputsX,
|
||||||
Positioned(
|
top: _inputsY,
|
||||||
left: _inputsX,
|
child: FlowNode(
|
||||||
top: _inputsY,
|
id: '__inputs__',
|
||||||
child: FlowNode(
|
title: 'inputs',
|
||||||
id: '__inputs__',
|
kind: NodeVisualKind.inputs,
|
||||||
title: 'inputs',
|
inputPortLabels: graph.inputs.keys
|
||||||
kind: NodeVisualKind.inputs,
|
.map((k) => '$k: ${graph.inputs[k]!.type}')
|
||||||
inputPortLabels: graph.inputs.keys
|
.toList(),
|
||||||
.map((k) => '$k: ${graph.inputs[k]!.type}')
|
selected: false,
|
||||||
.toList(),
|
),
|
||||||
selected: false,
|
),
|
||||||
),
|
// Outputs pseudo-node.
|
||||||
),
|
Positioned(
|
||||||
// Outputs pseudo-node.
|
left: outputsX,
|
||||||
Positioned(
|
top: _inputsY,
|
||||||
left: outputsX,
|
child: FlowNode(
|
||||||
top: _inputsY,
|
id: '__outputs__',
|
||||||
child: FlowNode(
|
title: 'outputs',
|
||||||
id: '__outputs__',
|
kind: NodeVisualKind.outputs,
|
||||||
title: 'outputs',
|
inputPortLabels: graph.outputs.keys.toList(),
|
||||||
kind: NodeVisualKind.outputs,
|
selected: false,
|
||||||
inputPortLabels: graph.outputs.keys.toList(),
|
),
|
||||||
selected: false,
|
),
|
||||||
),
|
// Step nodes — positioned absolutely, drag to
|
||||||
),
|
// move, click to select.
|
||||||
// Step nodes — positioned absolutely, drag to
|
for (final step in graph.steps)
|
||||||
// move, click to select.
|
_stepPositioned(step, layout, outputsX),
|
||||||
for (final step in graph.steps)
|
// Port hit-targets for connection drawing. A
|
||||||
_stepPositioned(step, layout, outputsX),
|
// transparent overlay positioned over each port
|
||||||
// Port hit-targets for connection drawing. A
|
// — easier to manage than per-port GestureDetectors
|
||||||
// transparent overlay positioned over each port
|
// inside the node widget because connection drags
|
||||||
// — easier to manage than per-port GestureDetectors
|
// need to cross node boundaries (start in one node,
|
||||||
// inside the node widget because connection drags
|
// end in another).
|
||||||
// need to cross node boundaries (start in one node,
|
..._portOverlays(graph, layout, outputsX),
|
||||||
// end in another).
|
if (_draft != null)
|
||||||
..._portOverlays(graph, layout, outputsX),
|
Positioned.fill(
|
||||||
if (_draft != null)
|
child: IgnorePointer(
|
||||||
Positioned.fill(
|
child: CustomPaint(
|
||||||
child: IgnorePointer(
|
painter: EdgePainter(
|
||||||
child: CustomPaint(
|
segments: [
|
||||||
painter: EdgePainter(
|
EdgeSegment(
|
||||||
segments: [
|
from: _draft!.from,
|
||||||
EdgeSegment(
|
to: _draft!.cursor,
|
||||||
from: _draft!.from,
|
accent: EdgeAccent.draftDrag,
|
||||||
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 ---
|
// --- Step positioning + drag ---
|
||||||
|
|
||||||
Widget _stepPositioned(FlowStep step, FlowLayout layout, double _) {
|
Widget _stepPositioned(FlowStep step, FlowLayout layout, double _) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue