// 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 _kPanZoomDevices = { PointerDeviceKind.touch, PointerDeviceKind.mouse, PointerDeviceKind.trackpad, PointerDeviceKind.unknown, }; /// Per-frame multiplicative scale-change clamp (flicker guard). const double _kMaxScaleChangePerFrame = 1.35; const double _kMinScaleChangePerFrame = 1 / _kMaxScaleChangePerFrame; 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 createState() => _PenInteractiveViewerState(); } class _PenInteractiveViewerState extends State with TickerProviderStateMixin { TransformationController get _transformer => widget.transformationController; final GlobalKey _childKey = GlobalKey(); Animation? _animation; Animation? _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; // Flicker guard: clamp the per-frame change so a single-frame touch // jitter can't pop the zoom and snap back. Absolute tracking means a // real pinch just resumes next frame. final double scaleChange = clampDouble( desiredScale / scale, _kMinScaleChangePerFrame, _kMaxScaleChangePerFrame, ); _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( 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( 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)), ); }