From 25400c8b827acad6c9e8d627c3fea9826f9f5282 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Mon, 22 Jun 2026 20:03:50 +0800 Subject: [PATCH] feat(pen): eraser delete-preview + pre-disable IV pan on stylus hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eraser preview (user request, "加一个淡一点的描边"): new EraserPreviewPainter shows the eraser circle and a faint red outline over the committed strokes the eraser currently overlaps, so you can see what is about to be deleted. Mounted only in eraser mode (tool or barrel/inverted signal) with a live cursor that follows the hovering/erasing pen; shares _eraserRadius/_pageAspect with the live erase so preview and action always agree. Kept in its own RepaintBoundary. Pen-feel fix (the "写字识别成单击" pan-steal): panEnabled now also requires the last pointer to not be a stylus. Windows fires stylus HOVER before contact, so _lastStylus is already true when the pen touches down -> the InteractiveViewer's pan is disabled BEFORE the stroke's first move, instead of one frame late. A 2+ pointer pinch still always pans (focal translation); a finger/mouse down flips _lastStylus back so finger-pan keeps working. Grounded in Rnote + Saber research: Saber uses the same button detection we have (buttons==kSecondaryButton || invertedStylus); the deeper zoom-flash / draw-vs- pan robustness wants a Saber-style forked InteractiveViewer (single recognizer, decide-at-start) -- scoped as the next step, not done here. 66/66 tests, linux build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/canvas/ink_painters.dart | 81 +++++++++++++++++++++++++++ lib/editor/canvas/pen_canvas.dart | 87 ++++++++++++++++++++++++++--- 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/lib/editor/canvas/ink_painters.dart b/lib/editor/canvas/ink_painters.dart index 4096e01..247b4be 100644 --- a/lib/editor/canvas/ink_painters.dart +++ b/lib/editor/canvas/ink_painters.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:perfect_freehand/perfect_freehand.dart' as pf; +import '../engine/stroke_eraser.dart' show strokeHit; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import 'pen_stroke.dart'; @@ -104,6 +105,86 @@ class StaticInkPainter extends CustomPainter { old.thinning != thinning; } +/// Eraser preview: shows the eraser circle and faintly highlights the committed +/// strokes the eraser would delete, so the user can see what is about to go. +/// Mounted only while the eraser is the active mode and the pen is near the +/// page; kept behind its own RepaintBoundary so it never dirties the ink layers. +class EraserPreviewPainter extends CustomPainter { + EraserPreviewPainter({ + required this.strokes, + required this.cursor, + required this.radius, + required this.aspect, + required this.pageSize, + this.thinning = kDefaultPenThinning, + }); + + final List strokes; + + /// Eraser center in normalized page coords, or null when no preview. + final PenPoint? cursor; + + /// Eraser radius as a fraction of page width (matches the live erase test). + final double radius; + + /// Page aspect (height / width) so the on-screen circle stays round. + final double aspect; + + final Size pageSize; + final double thinning; + + @override + void paint(Canvas canvas, Size size) { + final c = cursor; + if (c == null) return; + + // Faint outline on each stroke the eraser currently overlaps. + final highlight = Paint() + ..color = const Color(0xFFFF5252).withValues(alpha: 0.55) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..isAntiAlias = true; + for (final stroke in strokes) { + if (!strokeHit(stroke, c.x, c.y, radius, aspect: aspect)) continue; + final path = + buildStrokePath(stroke, pageSize, isComplete: true, thinning: thinning); + if (path.getBounds().isEmpty) continue; + canvas.drawPath(path, highlight); + } + + // The eraser circle itself (radius is a page-width fraction → px = r * w). + final center = Offset(c.x * pageSize.width, c.y * pageSize.height); + final rPx = radius * pageSize.width; + canvas.drawCircle( + center, + rPx, + Paint() + ..color = const Color(0xFF9E9E9E).withValues(alpha: 0.5) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0 + ..isAntiAlias = true, + ); + canvas.drawCircle( + center, + rPx, + Paint() + ..color = const Color(0x14000000) + ..style = PaintingStyle.fill, + ); + } + + @override + bool shouldRepaint(EraserPreviewPainter old) => + old.cursor?.x != cursor?.x || + old.cursor?.y != cursor?.y || + old.radius != radius || + old.aspect != aspect || + !identical(old.strokes, strokes) || + old.strokes.length != strokes.length || + old.pageSize != pageSize || + old.thinning != thinning; +} + /// Paints just the in-progress stroke (the live layer), kept behind its own /// RepaintBoundary so committed strokes don't repaint on every move. class LiveInkPainter extends CustomPainter { diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index 7c5d5b2..d5fcd0f 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -128,6 +128,45 @@ class _PenCanvasState extends State { /// inverted stylus), detected on hover/down. bool _eraserActive = false; + /// Eraser preview cursor (normalized page coords), or null when not in eraser + /// 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 => + widget.tool == CanvasTool.eraser || _eraserActive; + + /// Eraser radius as a fraction of page width (shared by the live erase and the + /// preview overlay so they always agree). + double get _eraserRadius => widget.strokeWidth * 2; + + /// Page aspect (height / width) so the eraser circle stays round on screen. + double get _pageAspect => widget.pageSize.width <= 0 + ? 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) { + setState(() => _eraserCursor = _toNormalized(globalPosition, null)); + } else if (_eraserCursor != null) { + setState(() => _eraserCursor = null); + } + } + // The explicit user toggle wins: if finger-drawing is ON, a single finger // draws even after a stylus has been seen. (Palm rejection when the toggle is // OFF is automatic — fingers simply never draw — and a 2nd pointer always @@ -269,7 +308,10 @@ class _PenCanvasState extends State { if (_eraserActive || widget.tool == CanvasTool.eraser) { _eraseAt(p); // Keep the stroke pointer reserved so moves keep erasing, but don't paint. - setState(() => _liveStroke = null); + setState(() { + _liveStroke = null; + _eraserCursor = p; + }); return; } _updateLiveStroke(); @@ -282,6 +324,7 @@ class _PenCanvasState extends State { if (_eraserActive || widget.tool == CanvasTool.eraser) { _eraseAt(p); + setState(() => _eraserCursor = p); return; } _livePoints.add(p); @@ -339,10 +382,8 @@ class _PenCanvasState extends State { /// stays round on screen (the page rect is not square). void _eraseAt(PenPoint? p) { if (p == null) return; - final radius = widget.strokeWidth * 2; // normalized (page-width fraction) - final aspect = widget.pageSize.width <= 0 - ? 1.0 - : widget.pageSize.height / widget.pageSize.width; + final radius = _eraserRadius; // normalized (page-width fraction) + final aspect = _pageAspect; for (var i = widget.strokes.length - 1; i >= 0; i--) { final stroke = widget.strokes[i]; if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue; @@ -375,17 +416,21 @@ 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. _dispatchHwButtonActions(); // Detect eraser (barrel button / inverted) while hovering. _eraserActive = _isEraserSignal(event); + // Live eraser-preview cursor follows the hovering pen. + _updateEraserCursor(event.position); } } 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 @@ -437,10 +482,17 @@ class _PenCanvasState extends State { @override Widget build(BuildContext context) { - // Pan only when NOT mid-stroke; while drawing we suppress IV pan so it - // can't fight the stroke. (A 2nd finger cancels the stroke first, so pinch - // re-enables pan/zoom immediately.) - final panEnabled = _drawPointer == null; + // 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); return Listener( onPointerHover: _onPointerHover, @@ -482,6 +534,23 @@ class _PenCanvasState extends State { ), ), ), + // Eraser preview: faint outline on strokes about to be deleted + + // the eraser circle. Mounted only in eraser mode with a cursor. + if (_isEraserMode && _eraserCursor != null) + Positioned.fill( + child: RepaintBoundary( + child: CustomPaint( + painter: EraserPreviewPainter( + strokes: widget.strokes, + cursor: _eraserCursor, + radius: _eraserRadius, + aspect: _pageAspect, + pageSize: widget.pageSize, + thinning: widget.thinning, + ), + ), + ), + ), // Live ink (current stroke only, isolated repaint). Positioned.fill( child: RepaintBoundary(