Files
BadNote/lib/editor/canvas/pen_interactive_viewer.dart
Akiba So 4a6fe7d05e
All checks were successful
CI / Windows build (push) Successful in 8m19s
fix: Surface pen pressure, zoom glitches, sticky notes, selection UX
Wire Win32 pressure into Dart, tighten pinch guards, use geometric shape
strokes, expand the ink palette, and replace scratch-link split view with
an on-page sticky that shares the sidecar repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:52:15 +08:00

520 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
import 'input_diagnostics.dart';
import 'pinch_scale_solver.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.
/// Device logs showed spikes ~1.30; keep the band under that so jumps die.
const double _kScaleGlitchHi = 1.18;
const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
/// During a 2-finger gesture the focal point (finger midpoint) should move
/// smoothly. A single-frame local jump beyond this is a Windows touch misread,
/// and the frame is dropped (position-jump guard).
const double _kFocalGlitchPx = 100.0;
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;
/// The recognizer's cumulative `details.scale` and the absolute scale we last
/// APPLIED, both as of the previous accepted frame. The pinch is driven
/// absolutely from these + the gesture-start snapshot — we never read the live
/// matrix back into the per-frame scale change. (Re-reading
/// `getMaxScaleOnAxis()` per frame was the flicker source: a single transient
/// mis-read/interleaved write made `desiredScale/liveScale` demand a ~1.31.4x
/// jump for one frame and snap back. The glitch guard missed it because the
/// spike sat just under the 1.4 threshold.)
double _lastRawScale = 1.0;
double _lastAppliedScale = 1.0;
/// The recognizer's cumulative `details.scale` AT THE CURRENT BASELINE (the
/// gesture start, or the last pointer-count re-baseline). The absolute target
/// is `_scaleStart * (details.scale / _rawScaleAtBaseline)`: dividing by this
/// re-normalizes the cumulative scale so it reads 1.0 at the baseline moment.
///
/// Without this, a mid-gesture re-baseline (a finger blips 2→1→2 — routine on
/// Windows touch) captured a fresh `_scaleStart` but left `details.scale` at
/// its un-normalized cumulative value, so the next frame computed
/// `_scaleStart * 0.40` and the zoom popped to a wrong scale then snapped back
/// (the reported flicker). Normalizing kills that pop at the source.
double _rawScaleAtBaseline = 1.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);
_lastRawScale = 1.0;
_lastAppliedScale = _scaleStart!;
_rawScaleAtBaseline = 1.0;
}
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;
// Anchor the new baseline to the CLEAN tracked scale (_lastAppliedScale),
// NOT a fresh matrix read-back. Windows touch flickers the pointer count
// (2↔1↔2) mid-pinch, firing this re-baseline spuriously; reading
// getMaxScaleOnAxis() at that glitchy instant popped _scaleStart to a
// noisy value, so the absolute map K = scaleStart / rawScaleAtBaseline
// oscillated frame-to-frame (the reported "zoom jump"). Using
// _lastAppliedScale makes the displayed scale CONTINUOUS across the
// re-baseline: target == _lastAppliedScale at this instant, regardless of
// any transient in the live matrix.
_scaleStart = _lastAppliedScale;
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
_lastRawScale = details.scale;
// Re-anchor the cumulative scale to THIS frame's details.scale so the next
// good frame resumes from _scaleStart (not _scaleStart × a stale ratio).
_rawScaleAtBaseline = details.scale;
InputDiagnostics.instance.recordRebaseline();
return;
}
final double focalJumpPx = details.focalPointDelta.distance;
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;
// Position-jump guard: during a pinch the focal midpoint should move
// smoothly; a big single-frame jump is a touch misread → drop the frame.
final bool focalDrop =
details.pointerCount >= 2 && focalJumpPx > _kFocalGlitchPx;
void record(double appliedChange, bool scaleDrop, bool focalDropped) {
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: scale,
appliedChange: appliedChange,
focalJumpPx: focalJumpPx,
scaleDrop: scaleDrop,
focalDrop: focalDropped,
);
}
switch (_gestureType!) {
case _GestureType.scale:
assert(_scaleStart != null);
// Per-frame finger-motion ratio from the recognizer's OWN cumulative
// scale — the clean, monotonic signal (verified against device logs).
// Crucially we do NOT divide by the live matrix scale here: feeding
// getMaxScaleOnAxis() back in is what let a single mis-read pop the zoom
// and snap back. A ratio outside the glitch band is a real multi-touch
// spike → drop the frame; absolute tracking means the next good frame
// resumes from the true finger span, so the spike never shows.
final double rawRatio =
_lastRawScale > 0 ? details.scale / _lastRawScale : 1.0;
final bool scaleDrop =
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
if (scaleDrop || focalDrop) {
record(1.0, scaleDrop, focalDrop);
return;
}
// Drive the transform ABSOLUTELY from the gesture-start snapshot: the
// target scale is `_scaleStart * details.scale`, and we re-anchor so the
// scene point that was under the focal at gesture start stays under the
// CURRENT focal (which also yields 2-finger pan for free). Closed form
// for a pure scale+translate matrix — no inversion, no live read-back —
// so an interleaved/transient matrix write can't survive into the next
// frame: every frame is fully re-derived from clean inputs.
// Absolute target scale, normalized against the baseline so a
// mid-gesture re-baseline (finger blip) can't pop the zoom. See
// pinch_scale_solver.dart for the full rationale.
final double targetScale = absolutePinchScale(
scaleStart: _scaleStart!,
rawScaleAtBaseline: _rawScaleAtBaseline,
rawScale: details.scale,
minScale: widget.minScale,
maxScale: widget.maxScale,
);
final Offset focal = details.localFocalPoint;
final double tx = focal.dx - targetScale * _referenceFocalPoint!.dx;
final double ty = focal.dy - targetScale * _referenceFocalPoint!.dy;
_transformer.value = Matrix4.identity()
..setEntry(0, 0, targetScale)
..setEntry(1, 1, targetScale)
..setEntry(2, 2, targetScale)
..setTranslationRaw(tx, ty, 0);
final double applied =
_lastAppliedScale > 0 ? targetScale / _lastAppliedScale : 1.0;
_lastRawScale = details.scale;
_lastAppliedScale = targetScale;
record(applied, false, false);
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;
if (focalDrop) {
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, true);
return;
}
final Offset translationChange =
focalPointScene - _referenceFocalPoint!;
_transformer.value =
_matrixTranslate(_transformer.value, translationChange);
_referenceFocalPoint = _transformer.toScene(details.localFocalPoint);
record(1.0, false, false);
}
}
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);
}