From 4fb431727e4e6b1d37f89be549b832028c392bae Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 09:47:58 +0800 Subject: [PATCH] feat(eraser): configurable size + stroke-eraser mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "优化橡皮擦工具,你优化在哪" — the eraser already did segment erase, but the radius was a hardcoded const with no size control and no whole-stroke mode. Add both, OneNote/Notability-style: - PenConfig: eraserRadius (0.005-0.1, default 0.02) + eraserWholeStroke bool, with copyWith / JSON / setters. - PenCanvas: uses widget.eraserRadius for the live hit area AND the cursor preview (they stay in sync); eraserWholeStroke=true removes the whole stroke on contact, false keeps the segment-split behavior. - pen_editor threads both from PenConfig. - pen-settings: new Eraser section — size slider + "Stroke Eraser" switch. Tests: pen_eraser_mode_widget proves point-eraser keeps the untouched ends while stroke-eraser deletes the whole stroke from the same pass. flutter analyze: 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/canvas/pen_canvas.dart | 28 ++++--- lib/editor/canvas/pen_editor_screen.dart | 3 + lib/editor/input/pen_config.dart | 47 ++++++++++- lib/editor/ui/pen_settings_page.dart | 25 ++++++ test/pen_eraser_mode_widget_test.dart | 101 +++++++++++++++++++++++ 5 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 test/pen_eraser_mode_widget_test.dart diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index 6982798..b9baf42 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -58,6 +58,8 @@ class PenCanvas extends StatefulWidget { this.thinning = kDefaultPenThinning, this.pressureGamma = kNaturalPressureGamma, this.pressureFloor = kNaturalPressureFloor, + this.eraserRadius = kDefaultEraserRadius, + this.eraserWholeStroke = false, this.sideButtonAction = PenButtonAction.eraser, this.eraserEndAction = PenButtonAction.eraser, this.onPenButtonAction, @@ -115,6 +117,15 @@ class PenCanvas extends StatefulWidget { /// scratchy near-zero width. From `PenConfig.pressureFloor`. final double pressureFloor; + /// Eraser radius as a fraction of page width (live hit area + cursor size). + /// From `PenConfig.eraserRadius`. + final double eraserRadius; + + /// When true the eraser removes a whole stroke on contact (OneNote-style); + /// when false it does a partial / segment erase. From + /// `PenConfig.eraserWholeStroke`. + final bool eraserWholeStroke; + /// Configured action for the pen's side barrel button (W3 — resolved against /// the native pen plugin's flags on Windows). final PenButtonAction sideButtonAction; @@ -180,12 +191,6 @@ class _PenCanvasState extends State { 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). A decisive fixed size — the old - /// strokeWidth*2 was so small that a pass removed only a couple of points and - /// the stroke visibly survived ("选中了的笔画也不见得能删掉"). - static const double _eraserRadius = 0.02; - /// Page aspect (height / width) so the eraser circle stays round on screen. double get _pageAspect => widget.pageSize.width <= 0 ? 1.0 @@ -416,13 +421,16 @@ class _PenCanvasState extends State { /// stays round on screen (the page rect is not square). void _eraseAt(PenPoint? p) { if (p == null) return; - final radius = _eraserRadius; // normalized (page-width fraction) + final radius = widget.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; - final pieces = - splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect); + // Stroke-eraser mode: a hit removes the entire stroke (empty replacement). + // Point-eraser mode (default): cut out the touched span, keep the rest. + final pieces = widget.eraserWholeStroke + ? const [] + : splitStrokeByCircle(stroke, p.x, p.y, radius, aspect: aspect); // Defensive no-op guard (strokeHit already passed, so a hit is expected). if (pieces.length == 1 && identical(pieces.first, stroke)) return; widget.onEraseStroke(i, pieces); @@ -584,7 +592,7 @@ class _PenCanvasState extends State { painter: EraserPreviewPainter( strokes: widget.strokes, cursor: _eraserCursor, - radius: _eraserRadius, + radius: widget.eraserRadius, aspect: _pageAspect, pageSize: widget.pageSize, ), diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index a1c13a3..631efe1 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -564,6 +564,9 @@ class _PenEditorScreenState extends State { // natural default when no config is loaded yet. pressureGamma: _penConfig?.value.pressureGamma ?? kNaturalPressureGamma, + eraserRadius: + _penConfig?.value.eraserRadius ?? kDefaultEraserRadius, + eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false, sideButtonAction: _penConfig?.value.sideButton ?? PenButtonAction.eraser, eraserEndAction: diff --git a/lib/editor/input/pen_config.dart b/lib/editor/input/pen_config.dart index accbf99..5ab7b9f 100644 --- a/lib/editor/input/pen_config.dart +++ b/lib/editor/input/pen_config.dart @@ -7,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import 'pressure_curve.dart' show kNaturalPressureGamma; +/// Default eraser radius as a fraction of page width (the legacy fixed value, +/// now the default of the configurable [PenConfig.eraserRadius]). +const double kDefaultEraserRadius = 0.02; + /// Action that can be triggered by a hardware pen button or the eraser end. enum PenButtonAction { none, @@ -29,12 +33,16 @@ class PenConfig { this.penWidth = 0.004, this.highlighterWidth = 0.02, this.pressureSensitivity = kDefaultPenThinning, + this.eraserRadius = kDefaultEraserRadius, + this.eraserWholeStroke = false, }) : assert(pressureGamma >= 0.3 && pressureGamma <= 3.0, 'pressureGamma must be in [0.3, 3.0]'), assert(palmRejectionMs >= 0.0 && palmRejectionMs <= 500.0, 'palmRejectionMs must be in [0, 500]'), assert(pressureSensitivity >= 0.0 && pressureSensitivity <= 1.0, - 'pressureSensitivity must be in [0, 1]'); + 'pressureSensitivity must be in [0, 1]'), + assert(eraserRadius >= 0.005 && eraserRadius <= 0.1, + 'eraserRadius must be in [0.005, 0.1]'); /// Which action fires when the side barrel button is held. final PenButtonAction sideButton; @@ -67,6 +75,16 @@ class PenConfig { /// export golden are unchanged. final double pressureSensitivity; + /// Eraser radius as a fraction of page width. Range [0.005, 0.1], default + /// [kDefaultEraserRadius]. Controls both the live erase hit area and the + /// on-screen eraser cursor. + final double eraserRadius; + + /// When true the eraser removes a WHOLE stroke on contact (OneNote-style + /// stroke eraser); when false it does a partial / segment erase (the default, + /// rnote-style point eraser). + final bool eraserWholeStroke; + PenConfig copyWith({ PenButtonAction? sideButton, PenButtonAction? eraserEnd, @@ -76,6 +94,8 @@ class PenConfig { double? penWidth, double? highlighterWidth, double? pressureSensitivity, + double? eraserRadius, + bool? eraserWholeStroke, }) { return PenConfig( sideButton: sideButton ?? this.sideButton, @@ -86,6 +106,8 @@ class PenConfig { penWidth: penWidth ?? this.penWidth, highlighterWidth: highlighterWidth ?? this.highlighterWidth, pressureSensitivity: pressureSensitivity ?? this.pressureSensitivity, + eraserRadius: eraserRadius ?? this.eraserRadius, + eraserWholeStroke: eraserWholeStroke ?? this.eraserWholeStroke, ); } @@ -98,6 +120,8 @@ class PenConfig { 'penWidth': penWidth, 'highlighterWidth': highlighterWidth, 'pressureSensitivity': pressureSensitivity, + 'eraserRadius': eraserRadius, + 'eraserWholeStroke': eraserWholeStroke, }; factory PenConfig.fromJson(Map json) { @@ -116,6 +140,9 @@ class PenConfig { highlighterWidth: (json['highlighterWidth'] as num?)?.toDouble() ?? 0.02, pressureSensitivity: (json['pressureSensitivity'] as num?)?.toDouble() ?? kDefaultPenThinning, + eraserRadius: + (json['eraserRadius'] as num?)?.toDouble() ?? kDefaultEraserRadius, + eraserWholeStroke: json['eraserWholeStroke'] as bool? ?? false, ); } @@ -131,7 +158,9 @@ class PenConfig { fingerDrawing == other.fingerDrawing && penWidth == other.penWidth && highlighterWidth == other.highlighterWidth && - pressureSensitivity == other.pressureSensitivity; + pressureSensitivity == other.pressureSensitivity && + eraserRadius == other.eraserRadius && + eraserWholeStroke == other.eraserWholeStroke; @override int get hashCode => Object.hash( @@ -143,6 +172,8 @@ class PenConfig { penWidth, highlighterWidth, pressureSensitivity, + eraserRadius, + eraserWholeStroke, ); } @@ -253,6 +284,18 @@ class PenConfigController extends ChangeNotifier { } /// Sets [PenConfig.pressureSensitivity]. Clamped to [0, 1]. + /// Sets [PenConfig.eraserRadius]. Clamped to [0.005, 0.1]. + Future setEraserRadius(double radius) async { + _value = _value.copyWith(eraserRadius: radius.clamp(0.005, 0.1)); + await _persist(); + } + + /// Sets [PenConfig.eraserWholeStroke] (true = OneNote-style stroke eraser). + Future setEraserWholeStroke(bool whole) async { + _value = _value.copyWith(eraserWholeStroke: whole); + await _persist(); + } + Future setPressureSensitivity(double sensitivity) async { _value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0)); notifyListeners(); diff --git a/lib/editor/ui/pen_settings_page.dart b/lib/editor/ui/pen_settings_page.dart index 3746454..b471140 100644 --- a/lib/editor/ui/pen_settings_page.dart +++ b/lib/editor/ui/pen_settings_page.dart @@ -161,6 +161,31 @@ class _PenSettingsSheet extends StatelessWidget { formatValue: (v) => v.toStringAsFixed(4), onChanged: controller.setHighlighterWidth, ), + + // ── Eraser ──────────────────────────────────────────────── + _SectionHeader( + title: 'Eraser', + icon: Icons.cleaning_services_outlined, + colorScheme: colorScheme, + ), + _SliderTile( + label: 'Eraser Size', + value: config.eraserRadius, + min: 0.005, + max: 0.1, + divisions: 19, + formatValue: (v) => v.toStringAsFixed(3), + onChanged: controller.setEraserRadius, + ), + SwitchListTile( + title: const Text('Stroke Eraser'), + subtitle: const Text( + 'Erase a whole stroke on contact (off: erase by segment)', + ), + value: config.eraserWholeStroke, + onChanged: controller.setEraserWholeStroke, + contentPadding: EdgeInsets.zero, + ), ], ); }, diff --git a/test/pen_eraser_mode_widget_test.dart b/test/pen_eraser_mode_widget_test.dart new file mode 100644 index 0000000..60eefa5 --- /dev/null +++ b/test/pen_eraser_mode_widget_test.dart @@ -0,0 +1,101 @@ +// Guards the two eraser modes (the "优化橡皮擦工具" work): the default point +// eraser cuts a stroke into surviving segments, while the new stroke-eraser +// mode removes the entire stroke on contact. Both are driven by the same +// stylus pass; only the eraserWholeStroke flag differs. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/canvas/pen_canvas.dart'; +import 'package:badnote/editor/canvas/pen_stroke.dart'; + +void main() { + const pageSize = Size(400, 600); + + // A long horizontal stroke spanning the page through the vertical center. + PenStroke longStroke() => PenStroke( + points: const [ + PenPoint(0.1, 0.5, 0.5), + PenPoint(0.3, 0.5, 0.5), + PenPoint(0.5, 0.5, 0.5), + PenPoint(0.7, 0.5, 0.5), + PenPoint(0.9, 0.5, 0.5), + ], + color: 0xFF000000, + width: 0.004, + kind: PenStrokeKind.pen, + ); + + Widget host({ + required void Function(int, List) onErase, + required bool wholeStroke, + }) { + final controller = TransformationController(); + addTearDown(controller.dispose); + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: pageSize.width, + height: pageSize.height, + child: PenCanvas( + pageWidget: Container(color: const Color(0xFFEEEEEE)), + pageSize: pageSize, + strokes: [longStroke()], + transformationController: controller, + tool: CanvasTool.eraser, + color: const Color(0xFF000000), + strokeWidth: 0.004, + eraserWholeStroke: wholeStroke, + onStrokeComplete: (_) {}, + onEraseStroke: onErase, + ), + ), + ), + ), + ); + } + + // Drag the eraser vertically through the page center (normalized 0.5, 0.5), + // grazing the MIDDLE of the long horizontal stroke. + Future erasePass(WidgetTester tester) async { + final center = tester.getCenter(find.byType(PenCanvas)); + final g = await tester.startGesture(center + const Offset(0, -15), + kind: PointerDeviceKind.stylus); + await g.moveBy(const Offset(0, 15)); + await g.moveBy(const Offset(0, 15)); + await g.up(); + await tester.pump(); + } + + testWidgets('point eraser (default) splits the stroke into surviving pieces', + (tester) async { + List? replacement; + await tester.pumpWidget(host( + onErase: (_, pieces) => replacement = pieces, + wholeStroke: false, + )); + await erasePass(tester); + + expect(replacement, isNotNull, reason: 'an erase should have fired'); + // Grazing the middle leaves the two ends as surviving sub-strokes. + expect(replacement!.length, greaterThanOrEqualTo(1)); + expect(replacement, isNotEmpty, + reason: 'point eraser keeps the untouched ends'); + }); + + testWidgets('stroke eraser removes the entire stroke on contact', + (tester) async { + List? replacement; + await tester.pumpWidget(host( + onErase: (_, pieces) => replacement = pieces, + wholeStroke: true, + )); + await erasePass(tester); + + expect(replacement, isNotNull, reason: 'an erase should have fired'); + expect(replacement, isEmpty, + reason: 'stroke eraser deletes the whole stroke, leaving no pieces'); + }); +}