Skip to content
Open
Show file tree
Hide file tree
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
86 changes: 82 additions & 4 deletions lib/src/view/notifier/scribble_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:scribble/scribble.dart';
import 'package:scribble/src/view/painting/point_to_offset_x.dart';
import 'package:scribble/src/view/simplification/sketch_simplifier.dart';
import 'package:value_notifier_tools/value_notifier_tools.dart';
import 'package:vector_math/vector_math_64.dart' show Vector3;

/// {@template scribble_notifier_base}
/// The base class for a notifier that controls the state of a [Scribble]
Expand Down Expand Up @@ -225,6 +226,20 @@ class ScribbleNotifier extends ScribbleNotifierBase
);
}

/// Sets the view transform matrix for pan/zoom functionality.
///
/// When set, pointer coordinates are transformed using the inverse of this
/// matrix before being stored, and the sketch is rendered using this
/// transform. This allows Scribble to work seamlessly with external pan/zoom
/// controllers like InteractiveViewer without hit-testing issues.
///
/// Set to `null` to disable view transformation.
void setViewTransform(Matrix4? transform) {
temporaryValue = value.copyWith(
viewTransform: transform,
);
}

/// Sets the color of the pen to the given color.
void setColor(Color color) {
temporaryValue = value.map(
Expand Down Expand Up @@ -293,6 +308,17 @@ class ScribbleNotifier extends ScribbleNotifierBase
@override
void onPointerDown(PointerDownEvent event) {
if (!value.supportedPointerKinds.contains(event.kind)) return;

// For singleTouchOnly mode, ignore additional touch pointers but allow
// mouse and stylus to work regardless
if (value.allowedPointersMode == ScribblePointerMode.singleTouchOnly &&
value.activePointerIds.isNotEmpty &&
event.kind == PointerDeviceKind.touch) {
// Don't capture this pointer - let it pass through to parent widgets
// (e.g., InteractiveViewer for pan/zoom)
return;
}

var s = value;

// Are there already pointers on the screen?
Expand All @@ -308,12 +334,18 @@ class ScribbleNotifier extends ScribbleNotifierBase
erasing: (s) => s,
);
} else if (value is Drawing) {
// When viewTransform is used, store the intended visual width so lines
// maintain constant thickness regardless of zoom level.
// Otherwise use canvas-coordinate width for backwards compatibility.
final lineWidth = value.viewTransform != null
? value.selectedWidth
: value.selectedWidth / value.scaleFactor;
s = (value as Drawing).copyWith(
pointerPosition: _getPointFromEvent(event),
activeLine: SketchLine(
points: [_getPointFromEvent(event)],
color: (value as Drawing).selectedColor,
width: value.selectedWidth / value.scaleFactor,
width: lineWidth,
),
);
}
Expand All @@ -326,6 +358,13 @@ class ScribbleNotifier extends ScribbleNotifierBase
@override
void onPointerUpdate(PointerMoveEvent event) {
if (!value.supportedPointerKinds.contains(event.kind)) return;

// For singleTouchOnly, ignore updates for pointers we didn't track
if (value.allowedPointersMode == ScribblePointerMode.singleTouchOnly &&
!value.activePointerIds.contains(event.pointer)) {
return;
}

if (!value.active) {
temporaryValue = value.copyWith(
pointerPosition: null,
Expand Down Expand Up @@ -357,6 +396,13 @@ class ScribbleNotifier extends ScribbleNotifierBase
@override
void onPointerUp(PointerUpEvent event) {
if (!value.supportedPointerKinds.contains(event.kind)) return;

// For singleTouchOnly, ignore events for pointers we didn't track
if (value.allowedPointersMode == ScribblePointerMode.singleTouchOnly &&
!value.activePointerIds.contains(event.pointer)) {
return;
}

final pos =
event.kind == PointerDeviceKind.mouse ? value.pointerPosition : null;
if (value is Drawing) {
Expand Down Expand Up @@ -391,6 +437,13 @@ class ScribbleNotifier extends ScribbleNotifierBase
@override
void onPointerCancel(PointerCancelEvent event) {
if (!value.supportedPointerKinds.contains(event.kind)) return;

// For singleTouchOnly, ignore events for pointers we didn't track
if (value.allowedPointersMode == ScribblePointerMode.singleTouchOnly &&
!value.activePointerIds.contains(event.pointer)) {
return;
}

if (value is Drawing) {
value = _finishLineForState(_addPoint(event, value)).copyWith(
pointerPosition: null,
Expand Down Expand Up @@ -448,11 +501,21 @@ class ScribbleNotifier extends ScribbleNotifierBase
}

ScribbleState? _erasePoint(PointerEvent event) {
// Get transformed position for erasing
var position = event.localPosition;
final viewTransform = value.viewTransform;
if (viewTransform != null) {
final inverse = Matrix4.inverted(viewTransform);
final transformed =
inverse.transform3(Vector3(position.dx, position.dy, 0));
position = Offset(transformed.x, transformed.y);
}

final filteredLines = value.sketch.lines
.where(
(l) => l.points.every(
(p) =>
(event.localPosition - p.asOffset).distance >
(position - p.asOffset).distance >
l.width + value.selectedWidth,
),
)
Expand All @@ -470,14 +533,29 @@ class ScribbleNotifier extends ScribbleNotifierBase
}

/// Converts a pointer event to the [Point] on the canvas.
///
/// When a viewTransform is set, the coordinates are transformed using the
/// inverse of that matrix to convert screen coordinates to canvas
/// coordinates.
Point _getPointFromEvent(PointerEvent event) {
var position = event.localPosition;

// Apply inverse view transform to get canvas coordinates
final viewTransform = value.viewTransform;
if (viewTransform != null) {
final inverse = Matrix4.inverted(viewTransform);
final transformed =
inverse.transform3(Vector3(position.dx, position.dy, 0));
position = Offset(transformed.x, transformed.y);
}

final p = event.pressureMin == event.pressureMax
? 0.5
: (event.pressure - event.pressureMin) /
(event.pressureMax - event.pressureMin);
return Point(
event.localPosition.dx,
event.localPosition.dy,
position.dx,
position.dy,
pressure: pressureCurve.transform(p),
);
}
Expand Down
23 changes: 21 additions & 2 deletions lib/src/view/painting/scribble_editing_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ class ScribbleEditingPainter extends CustomPainter with SketchLinePathMixin {

@override
void paint(Canvas canvas, Size size) {
// Apply view transform if set
final viewTransform = state.viewTransform;
if (viewTransform != null) {
canvas.save();
canvas.transform(viewTransform.storage);
}

// When viewTransform is applied, lines are stored with their intended
// visual width. We pass 1/scaleFactor so that after canvas transform
// (which scales by scaleFactor), lines appear at their stored visual width.
// This ensures constant line thickness regardless of zoom level.
final effectiveScaleFactor =
viewTransform != null ? 1.0 / state.scaleFactor : state.scaleFactor;

final paint = Paint()..style = PaintingStyle.fill;

final activeLine = state.map(
Expand All @@ -44,7 +58,7 @@ class ScribbleEditingPainter extends CustomPainter with SketchLinePathMixin {
if (activeLine != null) {
final path = getPathForLine(
activeLine,
scaleFactor: state.scaleFactor,
scaleFactor: effectiveScaleFactor,
);
if (path != null) {
paint.color = Color(activeLine.color);
Expand All @@ -66,10 +80,15 @@ class ScribbleEditingPainter extends CustomPainter with SketchLinePathMixin {
..strokeWidth = 1;
canvas.drawCircle(
state.pointerPosition!.asOffset,
state.selectedWidth / state.scaleFactor,
state.selectedWidth / effectiveScaleFactor,
paint,
);
}

// Restore canvas if we applied a transform
if (viewTransform != null) {
canvas.restore();
}
}

@override
Expand Down
27 changes: 25 additions & 2 deletions lib/src/view/painting/scribble_painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class ScribblePainter extends CustomPainter with SketchLinePathMixin {
required this.sketch,
required this.scaleFactor,
required this.simulatePressure,
this.viewTransform,
});

/// The [Sketch] to draw.
Expand All @@ -17,30 +18,52 @@ class ScribblePainter extends CustomPainter with SketchLinePathMixin {
/// {@macro view.state.scribble_state.scale_factor}
final double scaleFactor;

/// {@macro view.state.scribble_state.view_transform}
final Matrix4? viewTransform;

@override
final bool simulatePressure;

@override
void paint(Canvas canvas, Size size) {
// Apply view transform if set
if (viewTransform != null) {
canvas.save();
canvas.transform(viewTransform!.storage);
}

// When viewTransform is applied, lines are stored with their intended
// visual width. We pass 1/scaleFactor so that after canvas transform
// (which scales by scaleFactor), lines appear at their stored visual width.
// This ensures constant line thickness regardless of zoom level.
final effectiveScaleFactor =
viewTransform != null ? 1.0 / scaleFactor : scaleFactor;

final paint = Paint()..style = PaintingStyle.fill;

for (var i = 0; i < sketch.lines.length; ++i) {
final path = getPathForLine(
sketch.lines[i],
scaleFactor: scaleFactor,
scaleFactor: effectiveScaleFactor,
);
if (path == null) {
continue;
}
paint.color = Color(sketch.lines[i].color);
canvas.drawPath(path, paint);
}

// Restore canvas if we applied a transform
if (viewTransform != null) {
canvas.restore();
}
}

@override
bool shouldRepaint(ScribblePainter oldDelegate) {
return oldDelegate.sketch != sketch ||
oldDelegate.simulatePressure != simulatePressure ||
oldDelegate.scaleFactor != scaleFactor;
oldDelegate.scaleFactor != scaleFactor ||
oldDelegate.viewTransform != viewTransform;
}
}
62 changes: 41 additions & 21 deletions lib/src/view/scribble.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,32 +68,52 @@ class Scribble extends StatelessWidget {
sketch: state.sketch,
scaleFactor: state.scaleFactor,
simulatePressure: simulatePressure,
viewTransform: state.viewTransform,
),
),
),
),
);
return !state.active
? child
: GestureCatcher(
pointerKindsToCatch: state.supportedPointerKinds,
child: MouseRegion(
cursor: drawCurrentTool &&
state.supportedPointerKinds
.contains(PointerDeviceKind.mouse)
? SystemMouseCursors.none
: MouseCursor.defer,
onExit: notifier.onPointerExit,
child: Listener(
onPointerDown: notifier.onPointerDown,
onPointerMove: notifier.onPointerUpdate,
onPointerUp: notifier.onPointerUp,
onPointerHover: notifier.onPointerHover,
onPointerCancel: notifier.onPointerCancel,
child: child,
),
),
);
// For singleTouchOnly mode, don't use GestureCatcher so multi-touch
// events can bubble up to parent widgets (e.g., InteractiveViewer)
final isSingleTouchOnly =
state.allowedPointersMode == ScribblePointerMode.singleTouchOnly;

final listenerWidget = MouseRegion(
cursor: drawCurrentTool &&
state.supportedPointerKinds.contains(PointerDeviceKind.mouse)
? SystemMouseCursors.none
: MouseCursor.defer,
onExit: notifier.onPointerExit,
child: Listener(
onPointerDown: notifier.onPointerDown,
onPointerMove: notifier.onPointerUpdate,
onPointerUp: notifier.onPointerUp,
onPointerHover: notifier.onPointerHover,
onPointerCancel: notifier.onPointerCancel,
// Use translucent behavior for singleTouchOnly to allow events to
// pass through to parent widgets
behavior: isSingleTouchOnly
? HitTestBehavior.translucent
: HitTestBehavior.opaque,
child: child,
),
);

if (!state.active) {
return child;
}

// For singleTouchOnly, skip GestureCatcher to allow multi-touch events
// to reach parent widgets like InteractiveViewer
if (isSingleTouchOnly) {
return listenerWidget;
}

return GestureCatcher(
pointerKindsToCatch: state.supportedPointerKinds,
child: listenerWidget,
);
},
);
}
Expand Down
Loading