feat(canvas): interactive CanvasView — pan, zoom, click-select (T-322)

Adds hitTestCanvasNode (topmost node under a point, cards over the group
frames behind them, via the same viewport the painter draws with) and the
CanvasView widget: scroll-zoom, drag-pan, and click-to-select with a focus
ring. Node drag/resize and edit affordances follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 23:14:13 +02:00
co-authored by Claude Opus 4.8
parent d42c8c3236
commit 7700fc7a83
3 changed files with 208 additions and 0 deletions
@@ -74,6 +74,21 @@ Color? canvasContentColor(String? spec) {
};
}
/// The id of the topmost node under [local], or null. Uses the same
/// [CanvasViewport.fit] the painter draws with, so a click lands on what's
/// shown. Cards (drawn last) win over the group frames behind them.
String? hitTestCanvasNode(CanvasDoc doc, Offset local, Size size, {double zoom = 1, Offset pan = Offset.zero}) {
if (doc.isEmpty) return null;
final vp = CanvasViewport.fit(size, CanvasBounds.of(doc), zoom: zoom, pan: pan);
for (final n in doc.nodes.reversed) {
if (n is! GroupNode && vp.rectOf(n).contains(local)) return n.id;
}
for (final n in doc.nodes.reversed) {
if (n is GroupNode && vp.rectOf(n).contains(local)) return n.id;
}
return null;
}
Color? _hex(String s) {
var h = s.substring(1);
if (h.length == 3) h = h.split('').map((c) => '$c$c').join();
+74
View File
@@ -0,0 +1,74 @@
/// The interactive `.canvas` view (T-322): lays a [CanvasDoc] out with
/// [CanvasPainter] and wires pan (drag), zoom (scroll wheel), and click-select
/// via [hitTestCanvasNode] so the selection ring lands on what's drawn. Node
/// drag/resize and edit affordances layer on later.
library;
import 'package:clide/builtin/canvas/src/canvas_painter.dart';
import 'package:clide/src/canvas/json_canvas.dart';
import 'package:clide/widgets/src/clide_settings.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
class CanvasView extends StatefulWidget {
const CanvasView({super.key, required this.doc, this.onSelect});
final CanvasDoc doc;
/// Fired with the selected node's id (or null when the click misses).
final void Function(String? nodeId)? onSelect;
@override
State<CanvasView> createState() => _CanvasViewState();
}
class _CanvasViewState extends State<CanvasView> {
static const double _minZoom = 0.2, _maxZoom = 5;
double _zoom = 1;
Offset _pan = Offset.zero;
String? _selected;
@override
void didUpdateWidget(CanvasView old) {
super.didUpdateWidget(old);
if (!identical(old.doc, widget.doc)) {
_zoom = 1;
_pan = Offset.zero;
_selected = null;
}
}
void _onScroll(PointerScrollEvent e) {
final factor = e.scrollDelta.dy < 0 ? 1.1 : 0.9;
final next = (_zoom * factor).clamp(_minZoom, _maxZoom);
if (next != _zoom) setState(() => _zoom = next);
}
@override
Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface;
return LayoutBuilder(
builder: (ctx, constraints) {
final size = constraints.biggest;
return Listener(
onPointerSignal: (s) {
if (s is PointerScrollEvent) _onScroll(s);
},
child: GestureDetector(
onTapUp: (d) {
final hit = hitTestCanvasNode(widget.doc, d.localPosition, size, zoom: _zoom, pan: _pan);
if (hit != _selected) setState(() => _selected = hit);
widget.onSelect?.call(hit);
},
onPanUpdate: (d) => setState(() => _pan += d.delta),
child: CustomPaint(
size: size,
painter: CanvasPainter(doc: widget.doc, tokens: tokens, zoom: _zoom, pan: _pan, selected: _selected),
),
),
);
},
);
}
}