feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
All checks were successful
CI / Windows build (push) Successful in 18m35s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:27:56 +08:00
parent 25400c8b82
commit 7e405453e0
3 changed files with 452 additions and 33 deletions

View File

@@ -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<PenCanvas> {
/// 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<PenCanvas> {
? 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<PenCanvas> {
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<PenCanvas> {
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<PenCanvas> {
@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<PenCanvas> {
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,