feat(editor): right-click context menu + reset layout
Two operator-visible features filling concrete gaps the
jai_client editor exposed:
- Right-click on any step node opens a popup menu at the
cursor position with three actions:
* Duplicate — clones the step with a unique id
(<base>_2, <base>_3, …) and offsets its position so
the copy is visible next to the original.
* Disconnect all inputs — keeps every with-field key
but clears its expression value, so the step still
declares the same inputs but no longer wires them to
any upstream step.
* Delete — removes the step. Any dangling $refs in
downstream steps become run-time errors; we don't
silently rewrite their YAML because the surprise
would be worse than the explicit error.
- Floating "Reset layout" button on the canvas (next to
Fit to screen). Wipes the layout sidecar so AutoLayout
re-runs from scratch and the topological column
placement is restored. Auto-fits afterwards. Useful
when manual drags have drifted into spaghetti and the
operator wants a clean slate without losing the flow.
Implementation:
- FlowNode gains an `onContextMenu(Offset globalPos)`
parameter, wired to GestureDetector.onSecondaryTapDown.
- FlowEditorController.resetLayout() — clears layout,
re-seeds via AutoLayout, saves sidecar, notifies.
- The menu uses Flutter's showMenu() at the global
cursor position for native-feeling positioning.
Version bumped 0.2.2 -> 0.3.0 — first new operator-visible
feature set since the v0.2 WYSIWYG release.
Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
c15731fb1b
commit
296e5bfd01
4 changed files with 179 additions and 10 deletions
|
|
@ -167,6 +167,18 @@ class FlowEditorController extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Wipe every position in the sidecar and re-run auto-
|
||||
/// layout from scratch. Useful when manual drags have
|
||||
/// drifted into spaghetti and the operator wants a clean
|
||||
/// topological column layout again. The YAML is untouched.
|
||||
void resetLayout() {
|
||||
_layout = AutoLayout.layout(_graph, FlowLayout.empty());
|
||||
if (_activeName != null) {
|
||||
LayoutStore.saveSync(_activeName!, _layout);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Apply a graph-side edit (rename step, change `use`,
|
||||
/// adjust `with`, add/remove step, add edge). Emits fresh
|
||||
/// YAML into the text controller, then bumps the layout to
|
||||
|
|
|
|||
|
|
@ -202,10 +202,13 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
),
|
||||
),
|
||||
),
|
||||
// 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.
|
||||
// Floating canvas controls — pinned to the viewport
|
||||
// bottom-right so they don't drift with pan. Fit re-
|
||||
// centres on every node; Reset layout wipes the
|
||||
// sidecar so AutoLayout repositions from scratch
|
||||
// (useful when a flow's manual layout has drifted
|
||||
// into spaghetti and the operator wants a clean
|
||||
// starting point).
|
||||
Positioned(
|
||||
right: FaiSpace.md,
|
||||
bottom: FaiSpace.md,
|
||||
|
|
@ -213,12 +216,23 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
elevation: 2,
|
||||
child: IconButton(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _resetLayout,
|
||||
icon: const Icon(Icons.dashboard_outlined, size: 18),
|
||||
tooltip: 'Reset layout',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _fitToContent,
|
||||
icon: const Icon(Icons.fit_screen_outlined, size: 18),
|
||||
tooltip: 'Fit to screen',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -312,6 +326,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
delta,
|
||||
NodeGeometry.heightFor(step.with_.length),
|
||||
),
|
||||
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -707,6 +722,137 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Context menu actions ---
|
||||
|
||||
/// Right-click menu on a step node. Duplicate creates a
|
||||
/// sibling with a fresh id, same use + with-fields, placed
|
||||
/// slightly offset so the operator sees both. Delete drops
|
||||
/// the step entirely; any dangling refs in downstream
|
||||
/// steps become run-time errors with clear messages, which
|
||||
/// is by design (silently rewriting downstream YAML would
|
||||
/// be more surprising than the error).
|
||||
Future<void> _showStepContextMenu(FlowStep step, Offset globalPos) async {
|
||||
final theme = Theme.of(context);
|
||||
final overlay =
|
||||
Overlay.of(context).context.findRenderObject() as RenderBox?;
|
||||
if (overlay == null) return;
|
||||
final result = await showMenu<_StepAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(
|
||||
Rect.fromPoints(globalPos, globalPos),
|
||||
Offset.zero & overlay.size,
|
||||
),
|
||||
items: [
|
||||
const PopupMenuItem(
|
||||
value: _StepAction.duplicate,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.copy_outlined, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Duplicate'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: _StepAction.disconnectAll,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.link_off, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text('Disconnect all inputs'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _StepAction.delete,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.delete_outline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('Delete', style: TextStyle(color: theme.colorScheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!mounted || result == null) return;
|
||||
switch (result) {
|
||||
case _StepAction.duplicate:
|
||||
_duplicateStep(step);
|
||||
case _StepAction.disconnectAll:
|
||||
_disconnectAll(step);
|
||||
case _StepAction.delete:
|
||||
_deleteStep(step);
|
||||
}
|
||||
}
|
||||
|
||||
void _duplicateStep(FlowStep step) {
|
||||
final graph = widget.controller.graph;
|
||||
final layout = widget.controller.layout;
|
||||
// Generate a unique id: <base>, <base>_2, <base>_3, ...
|
||||
final existingIds = graph.steps.map((s) => s.id).toSet();
|
||||
var i = 2;
|
||||
var newId = '${step.id}_$i';
|
||||
while (existingIds.contains(newId)) {
|
||||
i++;
|
||||
newId = '${step.id}_$i';
|
||||
}
|
||||
widget.controller.applyGraphEdit(
|
||||
graph.withStepAdded(
|
||||
FlowStep(
|
||||
id: newId,
|
||||
use: step.use,
|
||||
with_: Map<String, dynamic>.from(step.with_),
|
||||
),
|
||||
),
|
||||
);
|
||||
// Offset the new node's position so it's visible next to
|
||||
// the original instead of stacking on top.
|
||||
final originalPos = layout.positions[step.id];
|
||||
if (originalPos != null) {
|
||||
widget.controller.moveStep(
|
||||
newId,
|
||||
NodePosition(originalPos.x + 40, originalPos.y + 40),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _disconnectAll(FlowStep step) {
|
||||
if (step.with_.isEmpty) return;
|
||||
// Keep the with-field keys; just clear their values so
|
||||
// the parameter list survives but no longer wires to any
|
||||
// upstream step.
|
||||
final cleared = {for (final k in step.with_.keys) k: ''};
|
||||
widget.controller.applyGraphEdit(
|
||||
widget.controller.graph.withStepUpdated(
|
||||
step.id,
|
||||
step.copyWith(with_: cleared),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteStep(FlowStep step) {
|
||||
widget.controller.applyGraphEdit(
|
||||
widget.controller.graph.withStepRemoved(step.id),
|
||||
);
|
||||
if (widget.controller.selectedStepId == step.id) {
|
||||
widget.controller.selectStep(null);
|
||||
}
|
||||
}
|
||||
|
||||
void _resetLayout() {
|
||||
widget.controller.resetLayout();
|
||||
// Re-fit once layout settles so the operator sees the
|
||||
// cleaned-up positions immediately.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _fitToContent();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Background ---
|
||||
|
||||
Widget _grid(ThemeData theme) {
|
||||
|
|
@ -722,6 +868,8 @@ class _FlowCanvasState extends State<FlowCanvas> {
|
|||
}
|
||||
}
|
||||
|
||||
enum _StepAction { duplicate, disconnectAll, delete }
|
||||
|
||||
enum _DraftSourceKind { step, inputsField }
|
||||
|
||||
enum _DraftTargetKind { step, outputsField }
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ class FlowNode extends StatelessWidget {
|
|||
final void Function(Offset delta)? onDrag;
|
||||
final VoidCallback? onDragEnd;
|
||||
|
||||
/// Right-click handler. Canvas wires this to a popup menu
|
||||
/// at the cursor position (Duplicate / Delete / Disconnect
|
||||
/// inputs etc.). `null` = no context menu offered.
|
||||
final void Function(Offset globalPos)? onContextMenu;
|
||||
|
||||
/// Live status from the most recent run, when this node is
|
||||
/// a step. Coloured dot in the header so the operator can
|
||||
/// glance at the canvas and see what's running.
|
||||
|
|
@ -84,6 +89,7 @@ class FlowNode extends StatelessWidget {
|
|||
this.onTap,
|
||||
this.onDrag,
|
||||
this.onDragEnd,
|
||||
this.onContextMenu,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -99,6 +105,9 @@ class FlowNode extends StatelessWidget {
|
|||
onTap: onTap,
|
||||
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
|
||||
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
|
||||
onSecondaryTapDown: onContextMenu == null
|
||||
? null
|
||||
: (details) => onContextMenu!(details.globalPosition),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
elevation: selected ? 6 : 2,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: fai_studio_flow_editor
|
||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||
version: 0.2.2
|
||||
version: 0.3.0
|
||||
publish_to: 'none'
|
||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue