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: