feat(pen): forked InteractiveViewer (stylus-exclusive draw) + zoom diagnostic
All checks were successful
CI / Windows build (push) Successful in 18m35s
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user