Files
BadNote/lib/editor/canvas/pen_interactive_viewer.dart
Akiba So ae9e070b46
All checks were successful
CI / Windows build (push) Successful in 17m58s
fix(pen): eraser lag/stuck-red/reliability; zoom glitch-reject; native input diag
Eraser (regression from the preview I added):
- LAG: the preview did setState on every hover/erase-move (rebuilding the whole
  canvas) and recomputed perfect_freehand getStroke per overlapped stroke per
  frame. Now the cursor is a ValueNotifier driving the preview layer's repaint
  directly (no canvas rebuild), and the highlight is a plain polyline of the
  point-runs inside the radius (no getStroke).
- STUCK RED ("一直红着"): the cursor was never cleared. Preview is now
  active-erase-only and cleared on pen up/cancel.
- "选中了的笔画也不见得能删掉": radius was strokeWidth*2 (tiny) so a pass removed
  ~2 points and the stroke survived. Now a decisive fixed 0.02 (page-width
  fraction). The highlight traces exactly the point-run that splitStrokeByCircle
  removes, so what turns red is what gets deleted.

Zoom: replace the per-frame scale CLAMP with glitch REJECTION — drop a frame
demanding an implausible per-frame scale jump (>1.4x or <0.71x; a real pinch is
≲1.15x/frame). A dropped frame catches up the next frame (absolute tracking), so
no lag, but the Windows multi-touch spike never shows. Pairs with the existing
pointer-count re-baseline.

Native diagnostic: ObservePenMessage now counts WM_POINTER* / PT_PEN / legacy
mouse messages it sees and emits them on the channel; PenInputService exposes
`debugSummary` and the overlay shows `native ptr=… pen=… mouse=… msg=0x…`. This
will tell us on-device whether WM_POINTER ever reaches the observer (→ buttons
recoverable) or Flutter is on a non-pointer path (→ not).

Dart: analyze clean, 66/66 tests, linux build green. Native compiles on CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:02:17 +08:00

432 lines
16 KiB
Dart

// lib/editor/canvas/pen_interactive_viewer.dart
//
// A focused fork of Flutter 3.44's InteractiveViewer, adapted for the pen-first
// canvas (clean-room model shared with Saber). Two deliberate changes vs stock:
//
// 1. The pan/zoom ScaleGestureRecognizer is restricted to NON-stylus devices
// (`supportedDevices` excludes stylus / invertedStylus). The pen therefore
// never reaches this recognizer — it only draws via the canvas `Listener`.
// This removes the gesture-arena fight and, crucially, the one-frame
// "pan-steal" where a stylus stroke's first frame was consumed as a pan
// (the "写字识别成单击" feel bug) because stock InteractiveViewer's
// `panEnabled` only updated a frame after the stroke had begun.
//
// 2. The per-frame scale change is clamped (`_kMin/_MaxScaleChangePerFrame`).
// Stock InteractiveViewer already damps focal jitter and guards the pan
// branch, but a single-frame multi-touch glitch can still spike
// `details.scale`, popping the zoom bigger/smaller for one frame and then
// snapping back (the reported pinch flicker). Clamping the per-update change
// swallows that spike without affecting a real (gradual) pinch, since scale
// is tracked absolutely from gesture start and simply catches up next frame.
//
// Everything else (scale-about-focal math, pan, fling inertia, mouse-wheel zoom)
// is Flutter's proven logic. The boundary/rotation/panAxis machinery is dropped
// because this canvas always uses an infinite boundary, free pan, and no
// rotation — so that code was provably a no-op here.
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/gestures.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/widgets.dart';
/// Devices allowed to pan/zoom. Stylus + invertedStylus are excluded so the pen
/// is owned exclusively by the drawing `Listener`.
const Set<PointerDeviceKind> _kPanZoomDevices = <PointerDeviceKind>{
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.unknown,
};
/// A real pinch changes scale only modestly per frame (≲1.15x at 60fps). A frame
/// demanding far more than this is a Windows multi-touch position glitch, not
/// intent — that frame is dropped so the zoom can't pop and snap back.
const double _kScaleGlitchHi = 1.4;
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
const double _kDrag = 0.0000135;
enum _GestureType { pan, scale }
/// Pan + zoom for the pen canvas. The pen never reaches this widget's gesture
/// recognizer; only touch / mouse / trackpad pan and zoom the shared transform.
class PenInteractiveViewer extends StatefulWidget {
const PenInteractiveViewer({
super.key,
required this.transformationController,
required this.child,
this.minScale = 0.5,
this.maxScale = 8.0,
this.panEnabled = true,
this.scaleEnabled = true,
this.scaleFactor = kDefaultMouseScrollToScaleFactor,
this.interactionEndFrictionCoefficient = _kDrag,
}) : assert(minScale > 0),
assert(maxScale >= minScale);
final TransformationController transformationController;
final Widget child;
final double minScale;
final double maxScale;
final bool panEnabled;
final bool scaleEnabled;
final double scaleFactor;
final double interactionEndFrictionCoefficient;
@override
State<PenInteractiveViewer> createState() => _PenInteractiveViewerState();
}
class _PenInteractiveViewerState extends State<PenInteractiveViewer>
with TickerProviderStateMixin {
TransformationController get _transformer => widget.transformationController;
final GlobalKey _childKey = GlobalKey();
Animation<Offset>? _animation;
Animation<double>? _scaleAnimation;
late Offset _scaleAnimationFocalPoint;
late AnimationController _controller;
late AnimationController _scaleController;
Offset? _referenceFocalPoint;
double? _scaleStart;
_GestureType? _gestureType;
/// Number of pointers in the active gesture. When it changes (a finger lands
/// or lifts, or a Windows touch dropout/re-acquire), we re-baseline instead of
/// applying a frame whose scale/focal still refer to the old finger set.
int _lastPointerCount = 0;
// --- Matrix helpers (infinite boundary → no clamping to bounds) -----------
Matrix4 _matrixTranslate(Matrix4 matrix, Offset translation) {
if (translation == Offset.zero) return matrix.clone();
return matrix.clone()
..translateByDouble(translation.dx, translation.dy, 0, 1);
}
Matrix4 _matrixScale(Matrix4 matrix, double scale) {
if (scale == 1.0) return matrix.clone();
assert(scale != 0.0);
final double currentScale = _transformer.value.getMaxScaleOnAxis();
final double clampedTotalScale = clampDouble(
currentScale * scale,
widget.minScale,
widget.maxScale,
);
final double clampedScale = clampedTotalScale / currentScale;
return matrix.clone()
..scaleByDouble(clampedScale, clampedScale, clampedScale, 1);
}
bool _gestureIsSupported(_GestureType? gestureType) => switch (gestureType) {
_GestureType.scale => widget.scaleEnabled,
_GestureType.pan || null => widget.panEnabled,
};
_GestureType _getGestureType(ScaleUpdateDetails details) {
final double scale = widget.scaleEnabled ? details.scale : 1.0;
return (scale - 1).abs() > 0 ? _GestureType.scale : _GestureType.pan;
}
// --- Gesture lifecycle ----------------------------------------------------
void _onScaleStart(ScaleStartDetails details) {
if (_controller.isAnimating) {
_controller.stop();
_controller.reset();
_animation?.removeListener(_handleInertiaAnimation);
_animation = null;
}
if (_scaleController.isAnimating) {
_scaleController.stop();
_scaleController.reset();
_scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null;
}
_gestureType = null;
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
}
void _onScaleUpdate(ScaleUpdateDetails details) {
final double scale = _transformer.value.getMaxScaleOnAxis();
_scaleAnimationFocalPoint = details.localFocalPoint;
// Re-baseline on any pointer-count change so a finger landing/lifting (or a
// Windows touch dropout) can't make scale/focal jump from the stale set.
// The transitional frame itself is skipped.
if (details.pointerCount != _lastPointerCount) {
_lastPointerCount = details.pointerCount;
_scaleStart = _transformer.value.getMaxScaleOnAxis();
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
return;
}
final Offset focalPointScene = _transformer.toScene(details.localFocalPoint);
if (_gestureType == _GestureType.pan) {
// A 2-finger gesture can start with no scale change; allow re-typing it.
_gestureType = _getGestureType(details);
} else {
_gestureType ??= _getGestureType(details);
}
if (!_gestureIsSupported(_gestureType)) return;
switch (_gestureType!) {
case _GestureType.scale:
assert(_scaleStart != null);
final double desiredScale = _scaleStart! * details.scale;
final double scaleChange = desiredScale / scale;
// Glitch rejection: drop a frame that demands an implausible per-frame
// scale jump (a Windows multi-touch position glitch). The next good
// frame resumes from the true finger positions, so the spike never
// shows — unlike clamping, which still applied a visible partial jump.
if (scaleChange > _kScaleGlitchHi || scaleChange < _kScaleGlitchLo) {
return;
}
_transformer.value = _matrixScale(_transformer.value, scaleChange);
// Keep the focal point anchored under the fingers across the scale.
final Offset focalPointSceneScaled =
_transformer.toScene(details.localFocalPoint);
_transformer.value = _matrixTranslate(
_transformer.value,
focalPointSceneScaled - _referenceFocalPoint!,
);
// Re-anchor only when the rounded focal actually drifted (jitter damp).
final Offset focalPointSceneCheck =
_transformer.toScene(details.localFocalPoint);
if (_round(_referenceFocalPoint!) != _round(focalPointSceneCheck)) {
_referenceFocalPoint = focalPointSceneCheck;
}
case _GestureType.pan:
assert(_referenceFocalPoint != null);
// Throw away near-scale frames so a stale reference can't jump the pan.
if (details.scale != 1.0) return;
final Offset translationChange =
focalPointScene - _referenceFocalPoint!;
_transformer.value =
_matrixTranslate(_transformer.value, translationChange);
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
}
}
void _onScaleEnd(ScaleEndDetails details) {
_scaleStart = null;
_referenceFocalPoint = null;
_lastPointerCount = 0;
_animation?.removeListener(_handleInertiaAnimation);
_scaleAnimation?.removeListener(_handleScaleAnimation);
_controller.reset();
_scaleController.reset();
if (!_gestureIsSupported(_gestureType)) return;
switch (_gestureType) {
case _GestureType.pan:
if (details.velocity.pixelsPerSecond.distance < kMinFlingVelocity) {
return;
}
final translationVector = _transformer.value.getTranslation();
final Offset translation =
Offset(translationVector.x, translationVector.y);
final FrictionSimulation frictionSimulationX = FrictionSimulation(
widget.interactionEndFrictionCoefficient,
translation.dx,
details.velocity.pixelsPerSecond.dx,
);
final FrictionSimulation frictionSimulationY = FrictionSimulation(
widget.interactionEndFrictionCoefficient,
translation.dy,
details.velocity.pixelsPerSecond.dy,
);
final double tFinal = _getFinalTime(
details.velocity.pixelsPerSecond.distance,
widget.interactionEndFrictionCoefficient,
);
_animation = Tween<Offset>(
begin: translation,
end: Offset(frictionSimulationX.finalX, frictionSimulationY.finalX),
).animate(CurvedAnimation(parent: _controller, curve: Curves.decelerate));
_controller.duration = Duration(milliseconds: (tFinal * 1000).round());
_animation!.addListener(_handleInertiaAnimation);
_controller.forward();
case _GestureType.scale:
if (details.scaleVelocity.abs() < 0.1) return;
final double scale = _transformer.value.getMaxScaleOnAxis();
final FrictionSimulation frictionSimulation = FrictionSimulation(
widget.interactionEndFrictionCoefficient * widget.scaleFactor,
scale,
details.scaleVelocity / 10,
);
final double tFinal = _getFinalTime(
details.scaleVelocity.abs(),
widget.interactionEndFrictionCoefficient,
effectivelyMotionless: 0.1,
);
_scaleAnimation = Tween<double>(
begin: scale,
end: frictionSimulation.x(tFinal),
).animate(
CurvedAnimation(parent: _scaleController, curve: Curves.decelerate));
_scaleController.duration = Duration(milliseconds: (tFinal * 1000).round());
_scaleAnimation!.addListener(_handleScaleAnimation);
_scaleController.forward();
case null:
break;
}
}
// --- Mouse wheel / trackpad zoom ------------------------------------------
void _receivedPointerSignal(PointerSignalEvent event) {
final double scaleChange;
if (event is PointerScrollEvent) {
if (event.kind == PointerDeviceKind.trackpad) {
// Trackpad scroll → pan.
if (!_gestureIsSupported(_GestureType.pan)) return;
final Offset localDelta = PointerEvent.transformDeltaViaPositions(
untransformedEndPosition: event.position + event.scrollDelta,
untransformedDelta: event.scrollDelta,
transform: event.transform,
);
final Offset focalPointScene = _transformer.toScene(event.localPosition);
final Offset newFocalPointScene =
_transformer.toScene(event.localPosition - localDelta);
_transformer.value = _matrixTranslate(
_transformer.value,
newFocalPointScene - focalPointScene,
);
return;
}
if (event.scrollDelta.dy == 0.0) return;
scaleChange = math.exp(-event.scrollDelta.dy / widget.scaleFactor);
} else if (event is PointerScaleEvent) {
scaleChange = event.scale;
} else {
return;
}
if (!_gestureIsSupported(_GestureType.scale)) return;
final Offset focalPointScene = _transformer.toScene(event.localPosition);
_transformer.value = _matrixScale(_transformer.value, scaleChange);
final Offset focalPointSceneScaled =
_transformer.toScene(event.localPosition);
_transformer.value = _matrixTranslate(
_transformer.value,
focalPointSceneScaled - focalPointScene,
);
}
void _handleInertiaAnimation() {
if (!_controller.isAnimating) {
_animation?.removeListener(_handleInertiaAnimation);
_animation = null;
_controller.reset();
return;
}
final translationVector = _transformer.value.getTranslation();
final Offset translation = Offset(translationVector.x, translationVector.y);
_transformer.value = _matrixTranslate(
_transformer.value,
_transformer.toScene(_animation!.value) - _transformer.toScene(translation),
);
}
void _handleScaleAnimation() {
if (!_scaleController.isAnimating) {
_scaleAnimation?.removeListener(_handleScaleAnimation);
_scaleAnimation = null;
_scaleController.reset();
return;
}
final double desiredScale = _scaleAnimation!.value;
final double scaleChange =
desiredScale / _transformer.value.getMaxScaleOnAxis();
final Offset referenceFocalPoint =
_transformer.toScene(_scaleAnimationFocalPoint);
_transformer.value = _matrixScale(_transformer.value, scaleChange);
final Offset focalPointSceneScaled =
_transformer.toScene(_scaleAnimationFocalPoint);
_transformer.value = _matrixTranslate(
_transformer.value,
focalPointSceneScaled - referenceFocalPoint,
);
}
void _handleTransformation() => setState(() {});
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_scaleController = AnimationController(vsync: this);
_transformer.addListener(_handleTransformation);
}
@override
void didUpdateWidget(PenInteractiveViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.transformationController != widget.transformationController) {
oldWidget.transformationController.removeListener(_handleTransformation);
widget.transformationController.addListener(_handleTransformation);
}
}
@override
void dispose() {
_controller.dispose();
_scaleController.dispose();
_transformer.removeListener(_handleTransformation);
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget child = Transform(
transform: _transformer.value,
child: KeyedSubtree(key: _childKey, child: widget.child),
);
child = OverflowBox(
alignment: Alignment.topLeft,
minWidth: 0.0,
minHeight: 0.0,
maxWidth: double.infinity,
maxHeight: double.infinity,
child: child,
);
child = ClipRect(child: child);
return Listener(
onPointerSignal: _receivedPointerSignal,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
supportedDevices: _kPanZoomDevices,
onScaleStart: _onScaleStart,
onScaleUpdate: _onScaleUpdate,
onScaleEnd: _onScaleEnd,
trackpadScrollCausesScale: false,
trackpadScrollToScaleFactor: Offset(0, -1 / widget.scaleFactor),
child: child,
),
);
}
}
double _getFinalTime(double velocity, double drag,
{double effectivelyMotionless = 10}) {
return math.log(effectivelyMotionless / velocity) / math.log(drag / 100);
}
Offset _round(Offset offset) {
return Offset(
double.parse(offset.dx.toStringAsFixed(9)),
double.parse(offset.dy.toStringAsFixed(9)),
);
}