Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 118 additions & 55 deletions lib/features/paint_editor/widgets/paint_canvas.dart
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,22 @@ class PaintCanvasState extends State<PaintCanvas> {

bool _hasPartialErasedAreas = false;

/// Tracks the number of active pointers to detect multi-touch gestures.
/// When more than one pointer is active, drawing is disabled to allow
/// pinch-to-zoom gestures.
int _activePointerCount = 0;

/// Tracks whether the current gesture started as a multi-touch gesture.
/// Used to prevent drawing when the user is performing a pinch-to-zoom.
bool _isMultiTouch = false;

/// Tracks the position of the first pointer for tap detection.
Offset? _pointerDownPosition;

/// Maximum distance in logical pixels between pointer down and up positions
/// for the interaction to be considered a tap rather than a drag gesture.
static const double _tapDistanceThreshold = 10.0;

bool get _isPartialEraser => widget.eraserMode == EraserMode.partial;
bool get _isEraserMode => _paintCtrl.mode == PaintMode.eraser;
bool get _isFreeStyleMode =>
Expand All @@ -142,13 +158,30 @@ class PaintCanvasState extends State<PaintCanvas> {
super.dispose();
}

/// This method is called when a scaling gesture for paint begins. It
/// captures the starting point of the gesture.
/// Handles the pointer down event for immediate response to touch/stylus
/// input.
///
/// It is not meant to be called directly but is an event handler for scaling
/// gestures.
void _onScaleStart(ScaleStartDetails details) {
final offset = details.localFocalPoint;
/// This uses the low-level [Listener] widget instead of [GestureDetector]
/// to eliminate gesture disambiguation delays, significantly reducing drawing
/// latency on devices like iPad with Apple Pencil.
void _onPointerDown(PointerDownEvent event) {
_activePointerCount++;
if (_activePointerCount > 1) {
// Multi-touch detected - disable drawing to allow pinch-to-zoom
_isMultiTouch = true;
// Cancel any ongoing drawing
if (_paintCtrl.busy) {
_paintCtrl
..setInProgress(false)
..reset();
_activePaintStreamCtrl.add(null);
}
return;
}

_pointerDownPosition = event.localPosition;
final offset = event.localPosition;

switch (widget.paintCtrl.mode) {
case PaintMode.moveAndZoom:
return;
Expand All @@ -158,6 +191,8 @@ class PaintCanvasState extends State<PaintCanvas> {
setState(() {});
return;
case PaintMode.polygon:
// Only add the point on pointer down; completion check happens on
// pointer up when we can verify this was a tap (not a drag gesture)
_addPolygonPoint(offset);
return;
default:
Expand All @@ -169,24 +204,24 @@ class PaintCanvasState extends State<PaintCanvas> {
}
}

/// Fires while the user is interacting with the screen to record paint.
///
/// This method is called during an ongoing scaling gesture to record
/// paint actions. It captures the current position and updates the
/// paint controller accordingly.
/// Handles the pointer move event for continuous drawing updates.
///
/// It is not meant to be called directly but is an event handler for scaling
/// gestures.
void _onScaleUpdate(ScaleUpdateDetails details) {
/// This provides immediate response to pointer movement without the
/// gesture disambiguation delay that occurs with [GestureDetector].
void _onPointerMove(PointerMoveEvent event) {
// Skip if multi-touch gesture is active (pinch-to-zoom)
if (_isMultiTouch || _activePointerCount > 1) return;

final offset = event.localPosition;

switch (widget.paintCtrl.mode) {
case PaintMode.moveAndZoom:
case PaintMode.polygon:
return;
case PaintMode.eraser:
_processEraserInput(details);
_processEraserInputAt(offset);
break;
default:
final offset = details.localFocalPoint;
if (!_paintCtrl.busy) {
widget.onRefresh();
_paintCtrl.setInProgress(true);
Expand All @@ -206,19 +241,45 @@ class PaintCanvasState extends State<PaintCanvas> {
}
}

/// Fires when the user stops interacting with the screen.
///
/// This method is called when a scaling gesture for paint ends. It
/// finalizes and records the paint action.
///
/// It is not meant to be called directly but is an event handler for scaling
/// gestures.
void _onScaleEnd(ScaleEndDetails details) {
/// Handles the pointer up event to finalize drawing.
void _onPointerUp(PointerUpEvent event) {
_activePointerCount = max(0, _activePointerCount - 1);

// If this was part of a multi-touch gesture, reset and return
if (_isMultiTouch) {
if (_activePointerCount == 0) {
_isMultiTouch = false;
}
return;
}

final offset = event.localPosition;

// Handle tap detection for polygon and other modes
if (_pointerDownPosition != null) {
final distance = (offset - _pointerDownPosition!).distance;
// If movement was minimal, treat as a tap
if (distance < _tapDistanceThreshold) {
_tapDownDetails = TapDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
);
// For polygon mode, check if the shape should be completed on tap
if (_paintCtrl.mode == PaintMode.polygon) {
_checkPolygonIsComplete();
}
widget.onTap(_tapDownDetails!);
_tapDownDetails = null;
}
}
_pointerDownPosition = null;

if (widget.paintCtrl.mode == PaintMode.moveAndZoom) {
return;
} else if (widget.paintCtrl.mode == PaintMode.eraser) {
// Eraser mode doesn't create paintings - it only removes existing ones.
// The removal is handled during pointer move via _processEraserInputAt.
if (_isPartialEraser) widget.onRemovePartialEnd(_hasPartialErasedAreas);

return;
}

Expand All @@ -237,6 +298,24 @@ class PaintCanvasState extends State<PaintCanvas> {
_createPainting(offsets);
}

/// Handles the pointer cancel event to clean up state.
void _onPointerCancel(PointerCancelEvent event) {
_activePointerCount = max(0, _activePointerCount - 1);
_pointerDownPosition = null;

if (_activePointerCount == 0) {
_isMultiTouch = false;
}

// Reset any ongoing drawing
if (_paintCtrl.busy) {
_paintCtrl
..setInProgress(false)
..reset();
_activePaintStreamCtrl.add(null);
}
}

Offset _rotatePoint(Offset point, Offset center, double angle) {
if (angle == 0) return point;

Expand All @@ -252,9 +331,12 @@ class PaintCanvasState extends State<PaintCanvas> {
center;
}

void _processEraserInput(ScaleUpdateDetails details) {
/// Processes eraser input at the given position.
///
/// This method handles both full stroke and partial erasing based on
/// the current [EraserMode].
void _processEraserInputAt(Offset focalPoint) {
List<String> removeIds = [];
final Offset focalPoint = details.localFocalPoint;
final double stackScale = widget.layerStackScaleFactor;
final Offset editorHalfSize = Offset(
widget.editorBodySize.width,
Expand Down Expand Up @@ -379,35 +461,16 @@ class PaintCanvasState extends State<PaintCanvas> {
return StreamBuilder(
stream: _activePaintStreamCtrl.stream,
builder: (context, snapshot) {
return GestureDetector(
// Use Listener instead of GestureDetector for immediate pointer
// response. This significantly reduces drawing latency on devices
// like iPad with Apple Pencil by eliminating gesture disambiguation
// delays.
return Listener(
behavior: HitTestBehavior.translucent,
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
onTapDown: (details) {
_tapDownDetails = details;
if (_paintCtrl.mode == PaintMode.polygon) {
_addPolygonPoint(details.localPosition);
_checkPolygonIsComplete();
} else if (_isFreeStyleMode || _isEraserMode) {
_onScaleStart(ScaleStartDetails(
focalPoint: details.localPosition,
localFocalPoint: details.localPosition));
}
},
onTapUp: (details) {
if (_isFreeStyleMode || _isEraserMode) {
_onScaleUpdate(ScaleUpdateDetails(
focalPoint: details.localPosition,
localFocalPoint: details.localPosition,
));
_onScaleEnd(ScaleEndDetails());
}
_tapDownDetails = null;
},
onTap: () {
if (_tapDownDetails != null) widget.onTap(_tapDownDetails!);
},
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
child: _paintCtrl.busy
? _paintCtrl.mode == PaintMode.blur ||
_paintCtrl.mode == PaintMode.pixelate
Expand Down