Skip to content

Commit e20bba1

Browse files
committed
feat: infinite canvas
1 parent 609e5b3 commit e20bba1

File tree

7 files changed

+243
-95
lines changed

7 files changed

+243
-95
lines changed
Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,77 @@
11
import 'package:cookethflow/features/models/canvas_models/canvas_painter.dart';
2+
import 'package:cookethflow/features/workspace/providers/canvas_provider.dart';
23
import 'package:cookethflow/features/workspace/providers/workspace_provider.dart';
34
import 'package:flutter/material.dart';
45
import 'package:provider/provider.dart';
6+
import 'package:vector_math/vector_math_64.dart'; // Required for Vector3.
57

68
class CanvasPage extends StatelessWidget {
79
const CanvasPage({super.key});
810

911
@override
1012
Widget build(BuildContext context) {
11-
return Consumer<WorkspaceProvider>(
12-
builder: (context, provider, child) {
13+
return Consumer2<WorkspaceProvider, CanvasProvider>(
14+
builder: (context, workspaceProvider, canvasProvider, child) {
1315
return Scaffold(
14-
backgroundColor: provider.currentWorkspaceColor,
16+
backgroundColor: workspaceProvider.currentWorkspaceColor,
1517
body: MouseRegion(
1618
onHover: (event) {
17-
provider.syncCanvasObject(event.position);
19+
// Mouse hover position is directly event.localPosition relative to MouseRegion.
20+
// InteractiveViewer's transform is applied implicitly by its child's position.
21+
// We need to apply the inverse of the current transformation to get canvas coordinates.
22+
final Matrix4 transform = canvasProvider.transformationController.value;
23+
final Matrix4? inverseTransform = Matrix4.tryInvert(transform);
24+
25+
if (inverseTransform == null) {
26+
// This case should be rare for valid transforms.
27+
return;
28+
}
29+
30+
// Transform the local position (relative to InteractiveViewer's viewport)
31+
// to canvas coordinates.
32+
final Vector3 transformed = inverseTransform.transform3(Vector3(event.localPosition.dx, event.localPosition.dy, 0));
33+
final Offset canvasCoordinates = Offset(transformed.x, transformed.y);
34+
35+
workspaceProvider.syncCanvasObject(canvasCoordinates);
1836
},
19-
child: GestureDetector(
20-
onPanDown: provider.onPanDown,
21-
onPanUpdate: provider.onPanUpdate,
22-
onPanEnd: provider.onPanEnd,
23-
child: CustomPaint(
24-
size: MediaQuery.of(context).size,
25-
painter: CanvasPainter(
26-
userCursors: provider.userCursors,
27-
canvasObjects: provider.canvasObjects,
28-
currentlySelectedObjectId:
29-
provider.currentlySelectedObjectId,
30-
handleRadius: provider.handleRadius,
37+
child: InteractiveViewer(
38+
transformationController: canvasProvider.transformationController,
39+
minScale: 0.1, // Minimum zoom level
40+
maxScale: 4.0, // Maximum zoom level
41+
boundaryMargin: const EdgeInsets.all(double.infinity), // Allows "infinite" panning
42+
constrained: false, // Important: allows content to go beyond initial bounds
43+
// InteractiveViewer automatically handles mouse drag for pan and scroll wheel for zoom.
44+
// We do NOT put onPanDown, onPanUpdate directly on InteractiveViewer as it will conflict
45+
// with the child GestureDetector for object manipulation.
46+
// We let InteractiveViewer manage its own pan/zoom gestures.
47+
// The GestureDetector below will handle *object* interactions.
48+
child: GestureDetector(
49+
// Use onTapDown for adding new nodes when not in pointer mode,
50+
// and for initiating object selection/move/resize.
51+
onPanDown: (details) {
52+
// details.localPosition is already relative to the GestureDetector's parent (InteractiveViewer's child).
53+
// This means it's already in the canvas coordinate system!
54+
workspaceProvider.onPanDown(DragDownDetails(globalPosition: details.localPosition));
55+
},
56+
onPanUpdate: (details) {
57+
// details.localPosition and details.delta are already in canvas coordinates.
58+
workspaceProvider.onPanUpdate(DragUpdateDetails(
59+
globalPosition: details.localPosition,
60+
delta: details.delta,
61+
));
62+
},
63+
onPanEnd: workspaceProvider.onPanEnd,
64+
child: CustomPaint(
65+
// Set a large, arbitrary size for the CustomPaint.
66+
// The actual drawing will occur based on the canvas coordinates of your objects.
67+
// InteractiveViewer will handle the viewport.
68+
size: const Size(20000, 20000), // Sufficiently large "infinite" canvas
69+
painter: CanvasPainter(
70+
userCursors: workspaceProvider.userCursors,
71+
canvasObjects: workspaceProvider.canvasObjects,
72+
currentlySelectedObjectId: workspaceProvider.currentlySelectedObjectId,
73+
handleRadius: workspaceProvider.handleRadius,
74+
),
3175
),
3276
),
3377
),
@@ -36,4 +80,4 @@ class CanvasPage extends StatelessWidget {
3680
},
3781
);
3882
}
39-
}
83+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'package:cookethflow/core/utils/state_handler.dart';
2+
import 'package:cookethflow/features/workspace/providers/workspace_provider.dart';
3+
import 'package:flutter/widgets.dart'; // Import for TransformationController
4+
5+
class CanvasProvider extends StateHandler {
6+
final WorkspaceProvider _workspaceProvider;
7+
CanvasProvider(this._workspaceProvider) : super();
8+
9+
final TransformationController _transformationController =
10+
TransformationController();
11+
12+
TransformationController get transformationController =>
13+
_transformationController;
14+
15+
void zoomIn() {
16+
_transformationController.value = Matrix4.identity()
17+
..translate(_transformationController.value.getTranslation().x, _transformationController.value.getTranslation().y)
18+
..scale(_transformationController.value.storage[0] * 1.2); // Zoom in by 20%
19+
notifyListeners();
20+
}
21+
22+
void zoomOut() {
23+
_transformationController.value = Matrix4.identity()
24+
..translate(_transformationController.value.getTranslation().x, _transformationController.value.getTranslation().y)
25+
..scale(_transformationController.value.storage[0] * 0.8); // Zoom out by 20%
26+
notifyListeners();
27+
}
28+
29+
void resetZoom() {
30+
_transformationController.value = Matrix4.identity();
31+
notifyListeners();
32+
}
33+
34+
double get currentZoomPercentage {
35+
final scale = _transformationController.value.storage[0];
36+
return scale * 100;
37+
}
38+
39+
@override
40+
void dispose() {
41+
_transformationController.dispose();
42+
super.dispose();
43+
}
44+
}

lib/features/workspace/providers/workspace_provider.dart

Lines changed: 71 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,9 @@ import 'package:uuid/uuid.dart';
2525
class WorkspaceProvider extends StateHandler {
2626
late SupabaseService _supabaseService;
2727
late DashboardProvider _dashboardProvider;
28-
28+
2929
// Initialize _currentWorkspace as null, it will be set by setWorkspace
30-
WorkspaceModel? _currentWorkspace;
30+
WorkspaceModel? _currentWorkspace;
3131

3232
WorkspaceProvider(this._supabaseService, this._dashboardProvider) : super() {
3333
_myId = _supabaseService.supabase.auth.currentUser?.id ?? const Uuid().v4();
@@ -48,11 +48,11 @@ class WorkspaceProvider extends StateHandler {
4848
String? _currentlySelectedObjectId;
4949
InteractionMode _interactionMode = InteractionMode.none;
5050
Offset? _panStartPoint;
51-
Offset _cursorPosition = const Offset(0, 0);
52-
static const double _defaultShapeSize = 100.0;
53-
static const double _handleRadius = 8.0;
51+
Offset _cursorPosition = const Offset(0, 0); // Now stores canvas coordinates
52+
static const double _defaultShapeSize = 100.0; // In canvas units
53+
static const double _handleRadius = 8.0; // In canvas units
5454
Color _currentWorkspaceColor = scaffoldColor;
55-
55+
5656
TextEditingController _workspaceNameController = TextEditingController(
5757
text: 'Workspace Name',
5858
);
@@ -92,15 +92,15 @@ class WorkspaceProvider extends StateHandler {
9292
// Handle case where workspace isn't found (e.g., navigate back, show error)
9393
return;
9494
}
95-
95+
9696
_workspaceNameController.text = _currentWorkspace!.name;
9797
_canvasObjects.clear(); // Clear existing objects when changing workspace
9898
_userCursors.clear(); // Clear cursors too
9999
_currentlySelectedObjectId = null; // Clear selected object
100100

101101
await _fetchCanvasObjects(); // Fetch objects for the new workspace
102102
_setupRealtimeChannel(_currentWorkspace!.id); // Set up realtime for new workspace
103-
103+
104104
notifyListeners();
105105
}
106106

@@ -118,6 +118,8 @@ class WorkspaceProvider extends StateHandler {
118118
.order('created_at', ascending: true);
119119

120120
for (final canvasObjectData in initialData) {
121+
// Ensure that `canvasObjectData['object']` is directly the JSONB content
122+
// and not wrapped in another object if not intended.
121123
final canvasObject = CanvasObject.fromJson(canvasObjectData['object']);
122124
_canvasObjects[canvasObject.id] = canvasObject;
123125
}
@@ -155,6 +157,7 @@ class WorkspaceProvider extends StateHandler {
155157
}
156158

157159
// --- Realtime Sync ---
160+
// Now expects canvas coordinates for cursorPosition
158161
Future<void> syncCanvasObject(Offset cursorPosition) {
159162
final myCursor = UserCursor(position: cursorPosition, id: _myId);
160163
if (_currentWorkspace == null || _canvasChannel == null) return Future.value();
@@ -163,8 +166,8 @@ class WorkspaceProvider extends StateHandler {
163166
event: Constants.broadcastEventName,
164167
payload: {
165168
'cursor': myCursor.toJson(),
166-
if (_currentlySelectedObjectId != null)
167-
'object': _canvasObjects[_currentlySelectedObjectId]?.toJson(),
169+
if (_currentlySelectedObjectId != null && _canvasObjects.containsKey(_currentlySelectedObjectId!)) // Ensure object exists
170+
'object': _canvasObjects[_currentlySelectedObjectId!]!.toJson(),
168171
'workspace_id': _currentWorkspace!.id, // Include workspace_id in broadcast
169172
},
170173
);
@@ -196,25 +199,26 @@ class WorkspaceProvider extends StateHandler {
196199
}
197200
}
198201

202+
// Modified onPanEnd to ensure object is saved when interaction stops
199203
void onPanEnd(DragEndDetails details) async {
200204
if (_currentlySelectedObjectId != null) {
201-
syncCanvasObject(_cursorPosition);
205+
syncCanvasObject(_cursorPosition); // Sync final position
206+
207+
final drawnObjectId = _currentlySelectedObjectId;
208+
if (drawnObjectId != null && _currentWorkspace != null) {
209+
// Save the current object state to the database
210+
await _supabaseService.supabase.from('canvas_objects').upsert({
211+
'id': drawnObjectId,
212+
'object': _canvasObjects[drawnObjectId]!.toJson(), // Ensure this toJson() matches your DB schema (jsonb)
213+
'workspace_id': _currentWorkspace!.id,
214+
});
215+
print('Canvas object ${drawnObjectId} upserted to DB.');
216+
}
202217
}
203218

204-
final drawnObjectId = _currentlySelectedObjectId;
205-
206219
_panStartPoint = null;
207220
_interactionMode = InteractionMode.none;
208221
notifyListeners();
209-
210-
if (drawnObjectId == null || _currentWorkspace == null) {
211-
return;
212-
}
213-
await _supabaseService.supabase.from('canvas_objects').upsert({
214-
'id': drawnObjectId,
215-
'object': _canvasObjects[drawnObjectId]!.toJson(),
216-
'workspace_id': _currentWorkspace!.id,
217-
});
218222
}
219223

220224
// --- UI/Interaction Logic ---
@@ -285,7 +289,8 @@ class WorkspaceProvider extends StateHandler {
285289
notifyListeners();
286290
}
287291

288-
void addNewNode(DragDownDetails details) {
292+
// Expects details.globalPosition to be in canvas coordinates
293+
void addNewNode(DragDownDetails details) async { // Make async to save immediately
289294
if (_currentWorkspace == null) {
290295
print("Cannot add node: No workspace selected.");
291296
return;
@@ -329,60 +334,86 @@ class WorkspaceProvider extends StateHandler {
329334
if (newObject != null) {
330335
_canvasObjects[newObject.id] = newObject;
331336
_currentlySelectedObjectId = newObject.id;
332-
_interactionMode = InteractionMode.moving;
337+
_interactionMode = InteractionMode.moving; // Set to moving immediately after creation
333338
notifyListeners();
339+
340+
// Immediately save the newly created object to the database
341+
try {
342+
await _supabaseService.supabase.from('canvas_objects').upsert({
343+
'id': newObject.id,
344+
'object': newObject.toJson(),
345+
'workspace_id': _currentWorkspace!.id,
346+
});
347+
print('New canvas object ${newObject.id} inserted into DB.');
348+
} catch (e) {
349+
print('Error inserting new canvas object: $e');
350+
}
334351
}
335352
}
336353

354+
// Expects details.globalPosition and details.delta to be in canvas coordinates
337355
void onPanDown(DragDownDetails details) {
338-
_cursorPosition = details.globalPosition;
339-
_panStartPoint = details.globalPosition;
356+
_cursorPosition = details.globalPosition; // This is now in canvas coordinates
357+
_panStartPoint = details.globalPosition; // This is now in canvas coordinates
340358

341359
_currentlySelectedObjectId = null;
342360
_interactionMode = InteractionMode.none;
343361
notifyListeners();
344362

345363
if (_currentMode == DrawMode.pointer) {
346-
if (_currentlySelectedObjectId != null) {
347-
final selectedObject = _canvasObjects[_currentlySelectedObjectId!]!;
348-
final rect = selectedObject.getBounds();
364+
// Prioritize handle interaction check
365+
for (final canvasObject in _canvasObjects.values.toList().reversed) {
366+
final rect = canvasObject.getBounds();
349367

368+
// Check for handle interaction. Handle radius also in canvas units.
350369
if ((details.globalPosition - rect.topLeft).distance < _handleRadius) {
370+
_currentlySelectedObjectId = canvasObject.id;
351371
_interactionMode = InteractionMode.resizingTopLeft;
372+
notifyListeners();
373+
return; // Exit after finding a handle
352374
} else if ((details.globalPosition - rect.topRight).distance < _handleRadius) {
375+
_currentlySelectedObjectId = canvasObject.id;
353376
_interactionMode = InteractionMode.resizingTopRight;
377+
notifyListeners();
378+
return;
354379
} else if ((details.globalPosition - rect.bottomLeft).distance < _handleRadius) {
380+
_currentlySelectedObjectId = canvasObject.id;
355381
_interactionMode = InteractionMode.resizingBottomLeft;
382+
notifyListeners();
383+
return;
356384
} else if ((details.globalPosition - rect.bottomRight).distance < _handleRadius) {
385+
_currentlySelectedObjectId = canvasObject.id;
357386
_interactionMode = InteractionMode.resizingBottomRight;
387+
notifyListeners();
388+
return;
358389
}
359390
}
360391

361-
if (_interactionMode == InteractionMode.none) {
362-
for (final canvasObject in _canvasObjects.values.toList().reversed) {
363-
if (canvasObject.intersectsWith(details.globalPosition)) {
364-
_currentlySelectedObjectId = canvasObject.id;
365-
_interactionMode = InteractionMode.moving;
366-
notifyListeners();
367-
break;
368-
}
392+
// If no handle interaction, check if we're clicking on an object to move it
393+
for (final canvasObject in _canvasObjects.values.toList().reversed) {
394+
if (canvasObject.intersectsWith(details.globalPosition)) { // details.globalPosition is now canvas coordinate
395+
_currentlySelectedObjectId = canvasObject.id;
396+
_interactionMode = InteractionMode.moving;
397+
notifyListeners();
398+
break; // Exit after selecting an object
369399
}
370400
}
371401
} else {
372-
addNewNode(details);
402+
addNewNode(details); // Call addNewNode if not in pointer mode
373403
}
374404
}
375405

406+
// Expects details.globalPosition and details.delta to be in canvas coordinates
376407
void onPanUpdate(DragUpdateDetails details) {
377-
_cursorPosition = details.globalPosition;
408+
_cursorPosition = details.globalPosition; // This is now in canvas coordinates
378409
if (_currentlySelectedObjectId == null) return;
379410

380411
final currentObject = _canvasObjects[_currentlySelectedObjectId!];
381412
if (currentObject == null) return;
382413

383414
switch (_interactionMode) {
384415
case InteractionMode.moving:
385-
_canvasObjects[_currentlySelectedObjectId!] = currentObject.move(details.delta);
416+
_canvasObjects[_currentlySelectedObjectId!] = currentObject.move(details.delta); // delta is already in canvas units
386417
break;
387418
case InteractionMode.resizingTopLeft:
388419
final newTopLeft = currentObject.getBounds().topLeft + details.delta;

0 commit comments

Comments
 (0)