feat(eraser): configurable size + stroke-eraser mode
Some checks failed
CI / Windows build (push) Has been cancelled

"优化橡皮擦工具,你优化在哪" — 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:47:58 +08:00
parent 299b9546a8
commit 4fb431727e
5 changed files with 192 additions and 12 deletions

View File

@@ -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<PenCanvas> {
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<PenCanvas> {
/// 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 <PenStroke>[]
: 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<PenCanvas> {
painter: EraserPreviewPainter(
strokes: widget.strokes,
cursor: _eraserCursor,
radius: _eraserRadius,
radius: widget.eraserRadius,
aspect: _pageAspect,
pageSize: widget.pageSize,
),

View File

@@ -564,6 +564,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// 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:

View File

@@ -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<String, dynamic> 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<void> 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<void> setEraserWholeStroke(bool whole) async {
_value = _value.copyWith(eraserWholeStroke: whole);
await _persist();
}
Future<void> setPressureSensitivity(double sensitivity) async {
_value = _value.copyWith(pressureSensitivity: sensitivity.clamp(0.0, 1.0));
notifyListeners();

View File

@@ -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,
),
],
);
},

View File

@@ -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<PenStroke>) 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<void> 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<PenStroke>? 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<PenStroke>? 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');
});
}