From 7e405453e0db16c29e2963512b1550fdaf299e91 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Mon, 22 Jun 2026 20:27:56 +0800 Subject: [PATCH] feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace stock InteractiveViewer with PenInteractiveViewer, a focused fork of Flutter 3.44's InteractiveViewer for our config (constrained=false, infinite boundary, no rotation — that machinery dropped as a no-op here). Two deliberate changes, grounded in the Rnote/Saber research: 1. The pan/zoom ScaleGestureRecognizer excludes stylus/invertedStylus via `supportedDevices`. The pen never reaches it, so a stylus stroke can no longer be stolen as a pan on its first frame (the "写字识别成单击" feel bug, caused by stock IV's panEnabled updating a frame after the stroke began). Drawing is owned solely by the canvas Listener; no arena fight, no panEnabled lag. The prior _lastStylus hover hack is removed (superseded). 2. Per-frame scale change is clamped (×0.74..×1.35). Stock IV already damps focal jitter and guards the pan branch, but a single-frame multi-touch glitch could still spike details.scale, popping the zoom bigger/smaller and snapping back (the reported pinch flicker). Clamping swallows the spike; a real (gradual) pinch is unaffected since scale tracks absolutely from gesture start. Cap is far above any real pinch (~1.1-1.2x/frame), so no felt lag. Everything else (scale-about-focal, pan, fling inertia, mouse-wheel zoom) is Flutter's proven logic verbatim. Also add an on-device input diagnostic (bug-report toggle): the existing pen readout already prints kind/pressure/buttons; now it also shows live zoom=now/min/max so the next device test captures (a) whether the side/eraser button arrives as buttons/invertedStylus, and (b) the value any residual pinch flash jumps to. 66/66 tests, analyze clean, linux build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/canvas/pen_canvas.dart | 36 +- lib/editor/canvas/pen_editor_screen.dart | 38 +- lib/editor/canvas/pen_interactive_viewer.dart | 411 ++++++++++++++++++ 3 files changed, 452 insertions(+), 33 deletions(-) create mode 100644 lib/editor/canvas/pen_interactive_viewer.dart diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index d5fcd0f..d4a9ab5 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -26,6 +26,7 @@ import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import '../input/pen_config.dart'; import '../input/pen_input_service.dart'; import 'ink_painters.dart'; +import 'pen_interactive_viewer.dart'; import 'pen_stroke.dart'; /// The active tool on the pen canvas. @@ -132,13 +133,6 @@ class _PenCanvasState extends State { /// mode / the pen is not near the page. Drives [EraserPreviewPainter]. PenPoint? _eraserCursor; - /// True when the most recent pointer was a stylus. Set on stylus HOVER, which - /// precedes contact on Windows, so the InteractiveViewer's pan is already - /// disabled BEFORE the stroke starts — killing the 1-frame pan-steal that - /// corrupts fast strokes (the "写字识别成单击" feel bug). A finger/mouse down - /// flips it back so finger-pan still works. - bool _lastStylus = false; - /// True when the eraser would act (eraser tool selected, or a barrel/inverted /// eraser signal is live). bool get _isEraserMode => @@ -153,11 +147,6 @@ class _PenCanvasState extends State { ? 1.0 : widget.pageSize.height / widget.pageSize.width; - void _setLastStylus(bool v) { - if (_lastStylus == v) return; - setState(() => _lastStylus = v); - } - /// Update (or clear) the eraser-preview cursor from a global pointer position. void _updateEraserCursor(Offset globalPosition) { if (_isEraserMode) { @@ -416,7 +405,6 @@ class _PenCanvasState extends State { void _onPointerHover(PointerHoverEvent event) { if (_isStylus(event.kind)) { - _setLastStylus(true); _emitPenDebug(event); // Fire edge-triggered button actions (undo / toggleTool) on hover so a // mapped barrel press works without first touching down. @@ -430,7 +418,6 @@ class _PenCanvasState extends State { void _onPointerDown(PointerDownEvent event) { if (event.kind == PointerDeviceKind.trackpad) return; - _setLastStylus(_isStylus(event.kind)); if (_isStylus(event.kind)) { _emitPenDebug(event); // Fire edge-triggered button actions for a direct pen-down (no prior @@ -482,17 +469,12 @@ class _PenCanvasState extends State { @override Widget build(BuildContext context) { - // Pan rules (pen-first): - // - A 2+ pointer pinch ALWAYS pans (the focal-point translation is part of - // zooming), regardless of pen state. - // - Otherwise pan only when NOT mid-stroke AND the last pointer was not a - // stylus. Because Windows fires stylus HOVER before contact, _lastStylus - // is already true when the pen touches down, so the InteractiveViewer's - // pan is disabled BEFORE the stroke's first move — no 1-frame pan-steal - // that would corrupt a fast flick into a tap. - final panEnabled = _activePointers.length >= 2 - ? true - : (_drawPointer == null && !_lastStylus); + // The PEN never reaches PenInteractiveViewer's recognizer (it excludes + // stylus), so a stylus stroke can never be stolen as a pan. panEnabled only + // governs touch/mouse: suppress pan while a single-finger / mouse stroke is + // in progress (finger-drawing mode); a 2nd pointer cancels the stroke first + // so a pinch re-enables pan/zoom immediately. + final panEnabled = _drawPointer == null; return Listener( onPointerHover: _onPointerHover, @@ -500,14 +482,12 @@ class _PenCanvasState extends State { onPointerMove: _onPointerMove, onPointerUp: _onPointerUp, onPointerCancel: _onPointerCancel, - child: InteractiveViewer( + child: PenInteractiveViewer( transformationController: widget.transformationController, minScale: widget.minScale, maxScale: widget.maxScale, panEnabled: panEnabled, scaleEnabled: true, - constrained: false, - boundaryMargin: const EdgeInsets.all(double.infinity), child: SizedBox( width: widget.pageSize.width, height: widget.pageSize.height, diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index f06c3aa..cbe4083 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -95,6 +95,13 @@ class _PenEditorScreenState extends State { String _penDebug = ''; bool _showPenDebug = false; + /// Live zoom-scale diagnostic (only tracked while the overlay is on), so we + /// can capture the value the pinch flash jumps to. `_zoomMin/_zoomMax` record + /// the extremes seen since the overlay was last enabled. + double _zoomNow = 1.0; + double _zoomMin = double.infinity; + double _zoomMax = 0.0; + // Tool state. CanvasTool _tool = CanvasTool.pen; Color _color = Colors.black; @@ -119,11 +126,23 @@ class _PenEditorScreenState extends State { // Begin listening to the native Windows pen plugin (barrel/eraser/tilt). // No-op on platforms without the plugin (W3). PenInputService.instance.start(); + _transform.addListener(_onTransformDebug); _initPersistence(); _initPenConfig(); _open(); } + /// Track the live zoom scale for the diagnostic overlay (no-op when off). + void _onTransformDebug() { + if (!_showPenDebug) return; + final s = _transform.value.getMaxScaleOnAxis(); + setState(() { + _zoomNow = s; + if (s < _zoomMin) _zoomMin = s; + if (s > _zoomMax) _zoomMax = s; + }); + } + Future _initPenConfig() async { final controller = await PenConfigController.load(); if (!mounted) { @@ -221,6 +240,7 @@ class _PenEditorScreenState extends State { scheduler.dispose(); } _document?.dispose(); + _transform.removeListener(_onTransformDebug); _transform.dispose(); _penConfig?.dispose(); PenInputService.instance.stop(); @@ -437,9 +457,10 @@ class _PenEditorScreenState extends State { padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6), child: Text( - _penDebug.isEmpty - ? 'hover / draw with the pen…' - : _penDebug, + '${_penDebug.isEmpty ? 'hover / draw with the pen…' : _penDebug}' + '\nzoom=${_zoomNow.toStringAsFixed(2)} ' + 'min=${_zoomMin.isFinite ? _zoomMin.toStringAsFixed(2) : '-'} ' + 'max=${_zoomMax.toStringAsFixed(2)}', style: TextStyle( fontFamily: 'monospace', fontSize: 12, @@ -600,8 +621,15 @@ class _PenEditorScreenState extends State { _ToolButton( icon: Icons.bug_report_outlined, selected: _showPenDebug, - tooltip: 'Pen pressure diagnostic', - onPressed: () => setState(() => _showPenDebug = !_showPenDebug), + tooltip: 'Pen + zoom diagnostic', + onPressed: () => setState(() { + _showPenDebug = !_showPenDebug; + if (_showPenDebug) { + _zoomMin = double.infinity; + _zoomMax = 0.0; + _zoomNow = _transform.value.getMaxScaleOnAxis(); + } + }), ), ], ), diff --git a/lib/editor/canvas/pen_interactive_viewer.dart b/lib/editor/canvas/pen_interactive_viewer.dart new file mode 100644 index 0000000..a320acf --- /dev/null +++ b/lib/editor/canvas/pen_interactive_viewer.dart @@ -0,0 +1,411 @@ +// 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; + + // --- 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; + _scaleStart = _transformer.value.getMaxScaleOnAxis(); + _referenceFocalPoint = _transformer.toScene(details.localFocalPoint); + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + final double scale = _transformer.value.getMaxScaleOnAxis(); + _scaleAnimationFocalPoint = details.localFocalPoint; + 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; + _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)), + ); +}