This commit is contained in:
@@ -33,7 +33,7 @@ class GraphEdgeData {
|
||||
///
|
||||
/// Interactions:
|
||||
/// - drag empty space to pan, pinch / scroll to zoom
|
||||
/// - drag a node to move it ([onNodeMoved] per frame, [onNodeDragEnd] once)
|
||||
/// - drag a node to move it ([onNodeMoved] fires once, at drag end)
|
||||
/// - tap the link button on a node, then tap another node to connect them
|
||||
/// ([onConnect]); connecting an already-linked pair is the caller's chance
|
||||
/// to toggle the edge off
|
||||
@@ -44,7 +44,6 @@ class NodeGraphCanvas extends StatefulWidget {
|
||||
required this.nodes,
|
||||
required this.edges,
|
||||
this.onNodeMoved,
|
||||
this.onNodeDragEnd,
|
||||
this.onConnect,
|
||||
this.onNodeTap,
|
||||
this.onNodeLongPress,
|
||||
@@ -53,8 +52,11 @@ class NodeGraphCanvas extends StatefulWidget {
|
||||
|
||||
final List<GraphNodeData> nodes;
|
||||
final List<GraphEdgeData> edges;
|
||||
|
||||
/// Called once, when a drag ends, with the final position. During the
|
||||
/// drag the canvas tracks positions locally so no parent rebuild (or
|
||||
/// persistence) happens per pointer event — that's what keeps it smooth.
|
||||
final void Function(String id, Offset position)? onNodeMoved;
|
||||
final void Function(String id)? onNodeDragEnd;
|
||||
final void Function(String fromId, String toId)? onConnect;
|
||||
final void Function(String id)? onNodeTap;
|
||||
final void Function(String id)? onNodeLongPress;
|
||||
@@ -76,6 +78,27 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
||||
/// this is what makes node dragging feel 1:1 with the mouse.
|
||||
String? _draggingId;
|
||||
|
||||
/// Live positions — the canvas is the source of truth while dragging,
|
||||
/// so a drag never triggers a parent rebuild per pointer event.
|
||||
late Map<String, Offset> _positions;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_positions = {for (final n in widget.nodes) n.id: n.position};
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(NodeGraphCanvas oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// Re-sync from the parent, but never yank the node under the cursor.
|
||||
final dragging = _draggingId;
|
||||
_positions = {
|
||||
for (final n in widget.nodes)
|
||||
n.id: n.id == dragging ? (_positions[n.id] ?? n.position) : n.position,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_transform.dispose();
|
||||
@@ -116,18 +139,25 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
||||
height: widget.canvasSize.height,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Static dot grid — painted once, never repaints on drag.
|
||||
Positioned.fill(
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(painter: _DotGridPainter()),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _GraphPainter(
|
||||
painter: _EdgesPainter(
|
||||
nodesById: nodesById,
|
||||
positions: _positions,
|
||||
edges: widget.edges,
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final node in widget.nodes)
|
||||
Positioned(
|
||||
left: node.position.dx,
|
||||
top: node.position.dy,
|
||||
left: (_positions[node.id] ?? node.position).dx,
|
||||
top: (_positions[node.id] ?? node.position).dy,
|
||||
// Raw pointer events instead of a pan GestureDetector:
|
||||
// no gesture-arena delay, no drag slop — the node
|
||||
// follows the cursor from the first pixel.
|
||||
@@ -139,15 +169,19 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
||||
? null
|
||||
: (event) {
|
||||
if (_draggingId != node.id) return;
|
||||
widget.onNodeMoved!(
|
||||
node.id,
|
||||
node.position + event.delta / _scale,
|
||||
);
|
||||
setState(() {
|
||||
_positions[node.id] =
|
||||
(_positions[node.id] ?? node.position) +
|
||||
event.delta / _scale;
|
||||
});
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
if (_draggingId == node.id) {
|
||||
setState(() => _draggingId = null);
|
||||
widget.onNodeDragEnd?.call(node.id);
|
||||
widget.onNodeMoved?.call(
|
||||
node.id,
|
||||
_positions[node.id] ?? node.position,
|
||||
);
|
||||
}
|
||||
},
|
||||
onPointerCancel: (_) {
|
||||
@@ -155,20 +189,22 @@ class _NodeGraphCanvasState extends State<NodeGraphCanvas> {
|
||||
setState(() => _draggingId = null);
|
||||
}
|
||||
},
|
||||
child: _NodeCard(
|
||||
node: node,
|
||||
isConnectSource: _connectingFrom == node.id,
|
||||
connectMode: _connectingFrom != null,
|
||||
showLinkButton: widget.onConnect != null,
|
||||
onTap: () => _handleNodeBodyTap(node.id),
|
||||
onLongPress: widget.onNodeLongPress == null
|
||||
? null
|
||||
: () => widget.onNodeLongPress!(node.id),
|
||||
onStartConnect: () => setState(() {
|
||||
_connectingFrom = _connectingFrom == node.id
|
||||
child: RepaintBoundary(
|
||||
child: _NodeCard(
|
||||
node: node,
|
||||
isConnectSource: _connectingFrom == node.id,
|
||||
connectMode: _connectingFrom != null,
|
||||
showLinkButton: widget.onConnect != null,
|
||||
onTap: () => _handleNodeBodyTap(node.id),
|
||||
onLongPress: widget.onNodeLongPress == null
|
||||
? null
|
||||
: node.id;
|
||||
}),
|
||||
: () => widget.onNodeLongPress!(node.id),
|
||||
onStartConnect: () => setState(() {
|
||||
_connectingFrom = _connectingFrom == node.id
|
||||
? null
|
||||
: node.id;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -326,19 +362,45 @@ class _NodeCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edges + dot grid painter
|
||||
// Painters — the static dot grid is separate from the edges so that a node
|
||||
// drag only repaints the (cheap) edge layer, never the thousands of dots.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _GraphPainter extends CustomPainter {
|
||||
_GraphPainter({required this.nodesById, required this.edges});
|
||||
class _DotGridPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
const step = 28.0;
|
||||
final points = <Offset>[
|
||||
for (double x = step; x < size.width; x += step)
|
||||
for (double y = step; y < size.height; y += step) Offset(x, y),
|
||||
];
|
||||
canvas.drawPoints(
|
||||
PointMode.points,
|
||||
points,
|
||||
Paint()
|
||||
..color = AppColors.surfaceContainerHigh
|
||||
..strokeWidth = 1.6
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DotGridPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _EdgesPainter extends CustomPainter {
|
||||
_EdgesPainter({
|
||||
required this.nodesById,
|
||||
required this.positions,
|
||||
required this.edges,
|
||||
});
|
||||
|
||||
final Map<String, GraphNodeData> nodesById;
|
||||
final Map<String, Offset> positions;
|
||||
final List<GraphEdgeData> edges;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
_paintDotGrid(canvas, size);
|
||||
|
||||
final edgePaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
@@ -349,12 +411,14 @@ class _GraphPainter extends CustomPainter {
|
||||
if (from == null || to == null) continue;
|
||||
|
||||
final start =
|
||||
from.position +
|
||||
(positions[edge.fromId] ?? from.position) +
|
||||
const Offset(
|
||||
NodeGraphCanvas.nodeWidth,
|
||||
NodeGraphCanvas.nodeHeight / 2,
|
||||
);
|
||||
final end = to.position + const Offset(0, NodeGraphCanvas.nodeHeight / 2);
|
||||
final end =
|
||||
(positions[edge.toId] ?? to.position) +
|
||||
const Offset(0, NodeGraphCanvas.nodeHeight / 2);
|
||||
|
||||
edgePaint.color = from.accent.withValues(alpha: 0.75);
|
||||
|
||||
@@ -384,22 +448,6 @@ class _GraphPainter extends CustomPainter {
|
||||
}
|
||||
}
|
||||
|
||||
void _paintDotGrid(Canvas canvas, Size size) {
|
||||
const step = 28.0;
|
||||
final points = <Offset>[
|
||||
for (double x = step; x < size.width; x += step)
|
||||
for (double y = step; y < size.height; y += step) Offset(x, y),
|
||||
];
|
||||
canvas.drawPoints(
|
||||
PointMode.points,
|
||||
points,
|
||||
Paint()
|
||||
..color = AppColors.surfaceContainerHigh
|
||||
..strokeWidth = 1.6
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GraphPainter oldDelegate) => true;
|
||||
bool shouldRepaint(_EdgesPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user