feat(editor): reduce-motion clamp + edge gradient + breathing pulse + style sheet
Four polish features in one push. 1. Reduce-motion clamp. FaiEditorStyle.clampedForA11y( disableAnimations: ...) returns a style with glass, gradient, flow animation, and node shadows forced off when the OS reduce-motion preference is set. FlowCanvas + FlowEditorPage call it on every build with MediaQuery.disableAnimationsOf(context) so operators on accessibility settings get a flat, static editor without an explicit Studio setting. 2. Edge gradient source→target. EdgePainter now accepts an optional colorEnd per segment and paints a linear shader along the bezier when both ends carry distinct type accents. In practice this draws attention to type mismatches: a text→json wire reads as a colour transition; a same-type wire stays a solid colour. ModuleSpec lookup at both edge endpoints feeds the colour pair. 3. Breathing pulse on running steps. FlowNode accepts a Listenable pulse; when status=running AND the canvas's _flowAnim is ticking, the node wraps in an AnimatedBuilder that drives a sine-wave shadow halo (alpha + blur + spread oscillating). Stops when the run completes. Gated by _style.flowAnimation so reduce-motion / minimal-style modes stay still. 4. In-editor style sheet. Bottom-right canvas chrome gains a ⚙ Style button that opens a modal bottom sheet with four SwitchListTiles (glass, gradient backdrop, flow animation, node shadows) plus 'Reset to default'. Edits live in _styleOverride on the canvas state — operator can tweak the look for the session without restarting or editing code. No persistence in this version; follow-up could pipe the override through SharedPreferences via the host. Bumps fai_studio_flow_editor to 0.10.0. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
b1fe765468
commit
a71f654162
6 changed files with 377 additions and 73 deletions
|
|
@ -104,4 +104,27 @@ class FaiEditorStyle {
|
|||
panelBackgroundAlpha: panelBackgroundAlpha ?? this.panelBackgroundAlpha,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a style clamped to the operator's accessibility
|
||||
/// preferences. When `disableAnimations` (the OS-level
|
||||
/// "Reduce Motion" toggle, surfaced through Flutter's
|
||||
/// MediaQuery) is set, motion-dependent features collapse
|
||||
/// to their off variants regardless of what the caller
|
||||
/// declared:
|
||||
/// - canvasBackdrop → flat
|
||||
/// - panelStyle → solid
|
||||
/// - flowAnimation → false
|
||||
/// - nodeShadows → false
|
||||
/// Tints + colours are unaffected.
|
||||
FaiEditorStyle clampedForA11y({required bool disableAnimations}) {
|
||||
if (!disableAnimations) return this;
|
||||
return FaiEditorStyle(
|
||||
panelStyle: EditorPanelStyle.solid,
|
||||
canvasBackdrop: EditorCanvasBackdrop.flat,
|
||||
flowAnimation: false,
|
||||
nodeShadows: false,
|
||||
panelBlurSigma: panelBlurSigma,
|
||||
panelBackgroundAlpha: panelBackgroundAlpha,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,7 +454,12 @@ outputs:
|
|||
// operator's first instinct is the right action rather
|
||||
// than staring at an empty grid.
|
||||
final hasSteps = _controller.graph.steps.isNotEmpty;
|
||||
final style = widget.style ?? FaiEditorStyle.modern;
|
||||
// Honor the OS reduce-motion preference for the
|
||||
// properties-panel glass effect — same clamp the canvas
|
||||
// applies to its own surfaces.
|
||||
final style = (widget.style ?? FaiEditorStyle.modern).clampedForA11y(
|
||||
disableAnimations: MediaQuery.disableAnimationsOf(context),
|
||||
);
|
||||
final glass = style.panelStyle == EditorPanelStyle.glass;
|
||||
final hasSelection = _controller.selectedStepId != null;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
// canvas; the painter itself is wrapped in IgnorePointer in
|
||||
// the canvas so nothing here eats taps meant for nodes.
|
||||
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Which horizontal edge of its host node a port sits on.
|
||||
|
|
@ -38,6 +40,14 @@ class EdgeSegment {
|
|||
/// bytes=orange, …) instead of one uniform muted grey.
|
||||
final Color? color;
|
||||
|
||||
/// Optional second colour for a source→target gradient.
|
||||
/// When both [color] and [colorEnd] are set, the painter
|
||||
/// draws the wire with a linear shader running from
|
||||
/// `color` at `from` to `colorEnd` at `to` — operators
|
||||
/// can read flow direction from the colour transition
|
||||
/// alone. When `colorEnd` is null the wire stays solid.
|
||||
final Color? colorEnd;
|
||||
|
||||
/// When true, the segment renders as marching dashes
|
||||
/// driven by the painter's `phase` parameter — used to
|
||||
/// show "data is flowing right now" for edges pointing at
|
||||
|
|
@ -52,6 +62,7 @@ class EdgeSegment {
|
|||
this.shortenFrom = true,
|
||||
this.shortenTo = true,
|
||||
this.color,
|
||||
this.colorEnd,
|
||||
this.animated = false,
|
||||
});
|
||||
}
|
||||
|
|
@ -115,6 +126,19 @@ class EdgePainter extends CustomPainter {
|
|||
final shortenedTo = Offset(seg.to.dx + toShift, seg.to.dy);
|
||||
final path = _bezier(shortenedFrom, shortenedTo);
|
||||
|
||||
// Source→target gradient: when both colours are set,
|
||||
// build a linear shader running from the segment's
|
||||
// `from` to its `to`. The wire's hue transitions along
|
||||
// the bezier — operators read direction from colour
|
||||
// alone without needing arrow heads.
|
||||
Shader? shader;
|
||||
if (seg.colorEnd != null && !seg.animated) {
|
||||
shader = ui.Gradient.linear(shortenedFrom, shortenedTo, [
|
||||
color,
|
||||
seg.colorEnd!,
|
||||
]);
|
||||
}
|
||||
|
||||
if (seg.animated) {
|
||||
// Underlying muted line so the path's geometry stays
|
||||
// visible between dash gaps.
|
||||
|
|
@ -129,10 +153,14 @@ class EdgePainter extends CustomPainter {
|
|||
_drawAnimatedDashes(canvas, path, color, strokeWidth);
|
||||
} else {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round;
|
||||
if (shader != null) {
|
||||
paint.shader = shader;
|
||||
} else {
|
||||
paint.color = color;
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,21 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
// pattern button on the bottom-right canvas controls.
|
||||
_CanvasPattern _pattern = _CanvasPattern.dots;
|
||||
|
||||
// Effective style for this build pass — `widget.style`
|
||||
// (or _styleOverride) clamped against MediaQuery's
|
||||
// reduce-motion preference. Set at the start of every
|
||||
// build so subsequent helpers (_buildSegments,
|
||||
// _stepPositioned, etc.) read a single coherent style
|
||||
// instead of separately querying the source + OS
|
||||
// preference.
|
||||
FaiEditorStyle _style = FaiEditorStyle.modern;
|
||||
|
||||
// Operator's runtime style override — set by the
|
||||
// bottom-right ⚙ Style sheet. When non-null, replaces
|
||||
// `widget.style` for the lifetime of the canvas state.
|
||||
// null = follow whatever the host passed.
|
||||
FaiEditorStyle? _styleOverride;
|
||||
|
||||
// ModuleSpec cache: capability identifier → declared
|
||||
// inputs/outputs. Populated lazily on each build by
|
||||
// walking the current graph's `step.use` values and
|
||||
|
|
@ -239,6 +254,15 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
final theme = Theme.of(context);
|
||||
final graph = widget.controller.graph;
|
||||
final layout = widget.controller.layout;
|
||||
// Clamp the host-supplied style against the OS reduce-
|
||||
// motion preference. Operators on accessibility settings
|
||||
// — or kiosk-mode deployments — get the same editor with
|
||||
// GPU-heavy blur, gradients, and dash animation switched
|
||||
// off. Helpers below read `_style` instead of `widget.style`
|
||||
// so the effective shape is single-sourced.
|
||||
_style = (_styleOverride ?? widget.style).clampedForA11y(
|
||||
disableAnimations: MediaQuery.disableAnimationsOf(context),
|
||||
);
|
||||
// Best-effort lazy module-spec fetch so the per-field
|
||||
// input/output ports paint on the next frame. Cheap when
|
||||
// every spec is already cached; only triggers async work
|
||||
|
|
@ -275,7 +299,7 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
// canvas depth; "flat" preset drops it for the older
|
||||
// single-surface look (lower visual chrome, useful for
|
||||
// low-spec terminals).
|
||||
decoration: widget.style.canvasBackdrop == EditorCanvasBackdrop.gradient
|
||||
decoration: _style.canvasBackdrop == EditorCanvasBackdrop.gradient
|
||||
? BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
|
|
@ -502,6 +526,12 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _openStyleSheet,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
tooltip: 'Style',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _resetLayout,
|
||||
icon: const Icon(Icons.dashboard_outlined, size: 18),
|
||||
|
|
@ -680,7 +710,15 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
kind: kindForStep(step),
|
||||
selected: selected,
|
||||
status: status,
|
||||
elevated: widget.style.nodeShadows,
|
||||
elevated: _style.nodeShadows,
|
||||
// Breathing-pulse on running steps when the active
|
||||
// style allows flow animation. The canvas's existing
|
||||
// _flowAnim ticks 0..1 during runs and is gated on
|
||||
// the same accessibility / style preferences, so we
|
||||
// can route it straight through.
|
||||
pulse: _style.flowAnimation && status == FlowNodeStatus.running
|
||||
? _flowAnim
|
||||
: null,
|
||||
onTap: () => widget.controller.selectStep(step.id),
|
||||
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
|
||||
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
|
||||
|
|
@ -867,19 +905,56 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
_hoveredEdge == edgeKey ||
|
||||
edge.fromId == widget.controller.selectedStepId ||
|
||||
edge.toId == widget.controller.selectedStepId;
|
||||
// Type-aware wire colour: edges leaving the inputs
|
||||
// endpoint carry the declared type's accent so the
|
||||
// operator can trace "this is the text input, this is
|
||||
// the bytes input" by colour alone. Edges leaving step
|
||||
// outputs use a softer "step result" colour because we
|
||||
// don't have module manifests yet to know the actual
|
||||
// output type. Once the hub exposes manifests, this
|
||||
// lookup grows.
|
||||
Color? wireColor;
|
||||
// Type-aware wire colours at both endpoints. Inputs-
|
||||
// endpoint side carries the declared input type's
|
||||
// accent; step-output side reads the ModuleSpec when
|
||||
// resolved so the wire's target colour matches the
|
||||
// declared output type. With both ends typed, the
|
||||
// painter draws a source→target gradient — operators
|
||||
// read flow direction from colour alone, no arrow
|
||||
// heads needed.
|
||||
final theme2 = Theme.of(context);
|
||||
Color? wireColorStart;
|
||||
if (edge.fromKind == EdgeEndpointKind.inputs) {
|
||||
final input = graph.inputs[edge.fromField];
|
||||
if (input != null) {
|
||||
wireColor = _typeAccent(input.type, Theme.of(context));
|
||||
wireColorStart = _typeAccent(input.type, theme2);
|
||||
}
|
||||
} else if (edge.fromKind == EdgeEndpointKind.step) {
|
||||
final fromStep = graph.steps.firstWhere(
|
||||
(s) => s.id == edge.fromId,
|
||||
orElse: () => const FlowStep(id: '__missing__', use: ''),
|
||||
);
|
||||
final fromSpec = _specForStep(fromStep);
|
||||
if (fromSpec != null) {
|
||||
final f = fromSpec.outputs.firstWhere(
|
||||
(f) => f.name == edge.fromField,
|
||||
orElse: () => const ModuleField(name: '', type: ''),
|
||||
);
|
||||
if (f.type.isNotEmpty) {
|
||||
wireColorStart = _typeAccent(f.type, theme2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Color? wireColorEnd;
|
||||
// outputs endpoint has no declared type of its own —
|
||||
// it's a pass-through. The wire reads as the source
|
||||
// type, so we leave wireColorEnd null and the painter
|
||||
// collapses to a solid wire.
|
||||
if (edge.toKind == EdgeEndpointKind.step) {
|
||||
final toStep = graph.steps.firstWhere(
|
||||
(s) => s.id == edge.toId,
|
||||
orElse: () => const FlowStep(id: '__missing__', use: ''),
|
||||
);
|
||||
final toSpec = _specForStep(toStep);
|
||||
if (toSpec != null) {
|
||||
final f = toSpec.inputs.firstWhere(
|
||||
(f) => f.name == edge.toField,
|
||||
orElse: () => const ModuleField(name: '', type: ''),
|
||||
);
|
||||
if (f.type.isNotEmpty) {
|
||||
wireColorEnd = _typeAccent(f.type, theme2);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Edge is "live" when its target step is currently
|
||||
|
|
@ -890,11 +965,19 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
? widget.controller.stepStatuses[edge.toId]
|
||||
: null;
|
||||
final animated =
|
||||
widget.style.flowAnimation && targetStatus == StepRunStatus.running;
|
||||
_style.flowAnimation && targetStatus == StepRunStatus.running;
|
||||
// Hover / selection always wins the colour treatment
|
||||
// — operators need a clear "I'm looking at this one"
|
||||
// signal that overrides the type colour.
|
||||
final color = highlight ? null : wireColor;
|
||||
final color = highlight ? null : (wireColorStart ?? wireColorEnd);
|
||||
final colorEnd = highlight ? null : (wireColorEnd ?? wireColorStart);
|
||||
// Only attach the gradient end-colour when both sides
|
||||
// resolved to a real type AND they differ. Same colour
|
||||
// both ends → solid wire (the painter's existing path).
|
||||
final gradientEnd =
|
||||
(color != null && colorEnd != null && color != colorEnd)
|
||||
? colorEnd
|
||||
: null;
|
||||
out.add(
|
||||
EdgeSegment(
|
||||
from: from,
|
||||
|
|
@ -903,6 +986,7 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
toSide: toSide,
|
||||
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
|
||||
color: color,
|
||||
colorEnd: gradientEnd,
|
||||
animated: animated,
|
||||
),
|
||||
);
|
||||
|
|
@ -1139,6 +1223,116 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
..scaleByDouble(level, level, 1, 1);
|
||||
}
|
||||
|
||||
/// Modal bottom sheet listing the four style toggles
|
||||
/// (glass, gradient backdrop, flow animation, node
|
||||
/// shadows). Edits are kept in `_styleOverride` for the
|
||||
/// lifetime of the canvas; "Reset to host default" drops
|
||||
/// the override and the host-supplied style wins again.
|
||||
Future<void> _openStyleSheet() async {
|
||||
// Seed from the current effective source (override or
|
||||
// host) so toggles open in the right state.
|
||||
final base = _styleOverride ?? widget.style;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (ctx) {
|
||||
var draft = base;
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setLocal) {
|
||||
Widget tile({
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
}) {
|
||||
return SwitchListTile(
|
||||
title: Text(title),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
value: value,
|
||||
onChanged: (v) {
|
||||
setLocal(() => onChanged(v));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('Editor style'),
|
||||
subtitle: const Text(
|
||||
'Toggles apply to this canvas for the rest of the session.',
|
||||
),
|
||||
trailing: TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _styleOverride = null);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: const Text('Reset to default'),
|
||||
),
|
||||
),
|
||||
tile(
|
||||
title: 'Frosted glass panels',
|
||||
subtitle: 'BackdropFilter blur on the properties panel.',
|
||||
value: draft.panelStyle == EditorPanelStyle.glass,
|
||||
onChanged: (v) {
|
||||
draft = draft.copyWith(
|
||||
panelStyle: v
|
||||
? EditorPanelStyle.glass
|
||||
: EditorPanelStyle.solid,
|
||||
);
|
||||
setState(() => _styleOverride = draft);
|
||||
},
|
||||
),
|
||||
tile(
|
||||
title: 'Gradient canvas backdrop',
|
||||
subtitle: 'Subtle diagonal gradient on the canvas.',
|
||||
value:
|
||||
draft.canvasBackdrop == EditorCanvasBackdrop.gradient,
|
||||
onChanged: (v) {
|
||||
draft = draft.copyWith(
|
||||
canvasBackdrop: v
|
||||
? EditorCanvasBackdrop.gradient
|
||||
: EditorCanvasBackdrop.flat,
|
||||
);
|
||||
setState(() => _styleOverride = draft);
|
||||
},
|
||||
),
|
||||
tile(
|
||||
title: 'Flow animation',
|
||||
subtitle:
|
||||
'Marching dashes + breathing pulse during runs.',
|
||||
value: draft.flowAnimation,
|
||||
onChanged: (v) {
|
||||
draft = draft.copyWith(flowAnimation: v);
|
||||
setState(() => _styleOverride = draft);
|
||||
},
|
||||
),
|
||||
tile(
|
||||
title: 'Node shadows',
|
||||
subtitle: 'Layered drop shadows under each step card.',
|
||||
value: draft.nodeShadows,
|
||||
onChanged: (v) {
|
||||
draft = draft.copyWith(nodeShadows: v);
|
||||
setState(() => _styleOverride = draft);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- Port overlays (drag handles for creating edges) ---
|
||||
|
||||
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
// just outside the card border so edges can visually anchor
|
||||
// to them.
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../model/flow_graph.dart';
|
||||
|
|
@ -170,6 +172,14 @@ class FlowNode extends StatelessWidget {
|
|||
/// glance at the canvas and see what's running.
|
||||
final FlowNodeStatus status;
|
||||
|
||||
/// 0..1 oscillating value driving the "breathing pulse"
|
||||
/// glow on running steps. The canvas hooks this to its
|
||||
/// flow-animation controller, so it's already gated on the
|
||||
/// active style's `flowAnimation` setting + on whether any
|
||||
/// step is actually running. When null (or status != running),
|
||||
/// no pulse is rendered.
|
||||
final Listenable? pulse;
|
||||
|
||||
const FlowNode({
|
||||
super.key,
|
||||
required this.id,
|
||||
|
|
@ -189,6 +199,7 @@ class FlowNode extends StatelessWidget {
|
|||
this.onDragEnd,
|
||||
this.onContextMenu,
|
||||
this.elevated = true,
|
||||
this.pulse,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -199,10 +210,54 @@ class FlowNode extends StatelessWidget {
|
|||
inputPortLabels.length,
|
||||
outputPortLabels.length,
|
||||
);
|
||||
final cardWidget = _card(theme, accent, height);
|
||||
// Breathing-pulse glow on running steps. The pulse
|
||||
// listenable is the canvas's own flow-animation
|
||||
// controller (0..1), so a single host-side tick drives
|
||||
// every running step's halo in sync. Skipped when no
|
||||
// animation source is wired or the step isn't running.
|
||||
if (pulse != null && status == FlowNodeStatus.running) {
|
||||
return SizedBox(
|
||||
width: NodeGeometry.width,
|
||||
height: height,
|
||||
child: GestureDetector(
|
||||
child: AnimatedBuilder(
|
||||
animation: pulse!,
|
||||
builder: (context, child) {
|
||||
// Derive a 0..1 breath value from the controller.
|
||||
// Use a sine wave so the glow eases in + out
|
||||
// rather than ramping linearly — reads as
|
||||
// "breathing" instead of "flickering".
|
||||
final t = (pulse! is Animation<double>)
|
||||
? (pulse! as Animation<double>).value
|
||||
: 0.0;
|
||||
final breath = 0.5 + 0.5 * math.sin(t * 2 * math.pi);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: accent.withValues(alpha: 0.18 + 0.22 * breath),
|
||||
blurRadius: 20 + 8 * breath,
|
||||
spreadRadius: 0.5 + 1.5 * breath,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: cardWidget,
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
width: NodeGeometry.width,
|
||||
height: height,
|
||||
child: cardWidget,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(ThemeData theme, Color accent, double height) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
|
||||
|
|
@ -261,7 +316,6 @@ class FlowNode extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: fai_studio_flow_editor
|
||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||
version: 0.9.0
|
||||
version: 0.10.0
|
||||
publish_to: 'none'
|
||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue