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
+119
View File
@@ -0,0 +1,119 @@
import 'package:clide/builtin/canvas/src/canvas_painter.dart';
import 'package:clide/builtin/canvas/src/canvas_view.dart';
import 'package:clide/src/canvas/json_canvas.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
import '../../helpers/widget_harness.dart';
void main() {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() => f.dispose());
Widget view(CanvasDoc doc, {void Function(String?)? onSelect, double side = 400}) => anchoredHarness(
f,
SizedBox(
width: side,
height: side,
child: CanvasView(doc: doc, onSelect: onSelect),
),
);
group('hitTestCanvasNode', () {
test('finds the card under the point; cards beat the group behind them', () {
const doc = CanvasDoc(
nodes: [
GroupNode(id: 'g', x: 0, y: 0, width: 400, height: 400),
TextNode(id: 't', x: 150, y: 150, width: 100, height: 100, text: 'hi'),
],
);
// 400×400 doc into an 800×600 canvas, scale 1.5, centred. The text node's
// centre (200,200 in canvas coords) → the card wins over the group.
const size = Size(800, 600);
final vp = CanvasViewport.fit(size, CanvasBounds.of(doc));
final centre = vp.rectOf(doc.nodes[1]).center;
expect(hitTestCanvasNode(doc, centre, size), 't');
// A point inside the group but outside the card hits the group.
final groupOnly = vp.rectOf(doc.nodes[0]).topLeft + const Offset(4, 4);
expect(hitTestCanvasNode(doc, groupOnly, size), 'g');
});
test('returns null off any node and on an empty doc', () {
const doc = CanvasDoc(
nodes: [TextNode(id: 't', x: 0, y: 0, width: 10, height: 10, text: '')],
);
expect(hitTestCanvasNode(doc, const Offset(-999, -999), const Size(400, 400)), isNull);
expect(hitTestCanvasNode(const CanvasDoc(), Offset.zero, const Size(400, 400)), isNull);
});
});
testWidgets('tapping a node selects it, tapping empty space clears it', (tester) async {
String? picked = '__unset__';
// One node fills the padded canvas, so its centre is the view centre.
const doc = CanvasDoc(
nodes: [TextNode(id: 'only', x: 0, y: 0, width: 100, height: 100, text: 'hi')],
);
await tester.pumpWidget(view(doc, onSelect: (id) => picked = id));
await tester.pump();
await tester.tap(find.byType(CanvasView)); // centre → the node
expect(picked, 'only');
await tester.tapAt(tester.getTopLeft(find.byType(CanvasView)) + const Offset(3, 3)); // padding → miss
expect(picked, isNull);
});
testWidgets('dragging pans; a reverse drag restores the hit location', (tester) async {
String? picked = '__unset__';
// Two small, spread-out nodes so each renders small enough that a pan moves
// it clear of its old screen spot (a single node would fill the pane).
const doc = CanvasDoc(
nodes: [
TextNode(id: 'a', x: 0, y: 0, width: 60, height: 60, text: 'a'),
TextNode(id: 'b', x: 400, y: 400, width: 60, height: 60, text: 'b'),
],
);
await tester.pumpWidget(view(doc, onSelect: (id) => picked = id));
await tester.pump();
final origin = tester.getTopLeft(find.byType(CanvasView));
final size = tester.getSize(find.byType(CanvasView));
final aSpot = origin + CanvasViewport.fit(size, CanvasBounds.of(doc)).rectOf(doc.nodes[0]).center;
await tester.tapAt(aSpot);
expect(picked, 'a'); // baseline hit
await tester.drag(find.byType(CanvasView), const Offset(140, 0));
await tester.pump();
await tester.tapAt(aSpot);
expect(picked, isNull); // node a panned away from its old spot
await tester.drag(find.byType(CanvasView), const Offset(-140, 0)); // equal, opposite → net zero
await tester.pump();
await tester.tapAt(aSpot);
expect(picked, 'a'); // panned back
});
testWidgets('a scroll signal zooms without throwing', (tester) async {
const doc = CanvasDoc(
nodes: [TextNode(id: 'a', x: 0, y: 0, width: 100, height: 100, text: '')],
);
await tester.pumpWidget(view(doc));
await tester.pump();
final centre = tester.getCenter(find.byType(CanvasView));
final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(centre));
await tester.sendEventToBinding(pointer.scroll(const Offset(0, -120)));
await tester.pump();
expect(find.byType(CanvasView), findsOneWidget);
});
testWidgets('an empty canvas takes a tap without throwing', (tester) async {
await tester.pumpWidget(view(const CanvasDoc()));
await tester.pump();
await tester.tap(find.byType(CanvasView));
expect(find.byType(CanvasView), findsOneWidget);
});
}