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:
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user