feat(pen): partial/segment erase + fix side-button while drawing
All checks were successful
CI / Windows build (push) Successful in 16m23s
All checks were successful
CI / Windows build (push) Successful in 16m23s
W4/P0 engine: add engine/stroke_eraser.dart (pure, aspect-corrected) with whole-stroke `strokeHit` + partial `splitStrokeByCircle`. Grazing a long stroke now CUTS it into surviving pieces instead of deleting it whole. Wired through PenCanvas.onEraseStroke (now (index, replacements)) → pen_editor_screen._eraseStroke (replaceRange); undo/persistence unchanged (whole-page snapshot). 8 new unit tests; 66/66 pass. Fix side-button (侧键): _isEraserSignal used `buttons == kSecondaryButton`, but tip-down + barrel = kStylusContact|kPrimaryStylusButton = 0x03, so the side button only registered on hover, never while drawing. Now a bitmask test. (Eraser-end/tilt remain blocked on the silent native badnote/pen channel — needs on-device native logging.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../engine/stroke_eraser.dart';
|
||||
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
|
||||
import '../input/pen_config.dart';
|
||||
import '../input/pen_input_service.dart';
|
||||
@@ -79,8 +80,11 @@ class PenCanvas extends StatefulWidget {
|
||||
/// Called with a finished stroke (normalized coords) to commit it.
|
||||
final void Function(PenStroke stroke) onStrokeComplete;
|
||||
|
||||
/// Called with the index of a committed stroke to erase (stroke-erase).
|
||||
final void Function(int strokeIndex) onEraseStroke;
|
||||
/// Called to replace committed stroke [strokeIndex] with its surviving pieces
|
||||
/// after a partial (segment) erase. An empty [replacements] list removes the
|
||||
/// stroke entirely (whole-stroke erase).
|
||||
final void Function(int strokeIndex, List<PenStroke> replacements)
|
||||
onEraseStroke;
|
||||
|
||||
/// User toggle: allow a single finger to draw. Forced off once a stylus is
|
||||
/// seen (palm rejection).
|
||||
@@ -159,7 +163,12 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
/// eraser-end actions (W3). Level-triggered, so holding the button keeps
|
||||
/// erasing — correct for an eraser.
|
||||
bool _isEraserSignal(PointerEvent event) {
|
||||
if (event.buttons == kSecondaryButton ||
|
||||
// BITMASK test, not equality: Flutter defines kPrimaryStylusButton == 0x02
|
||||
// == kSecondaryButton, and kStylusContact == 0x01. While the pen TIP is
|
||||
// down with the barrel pressed, event.buttons == 0x03, so `== kSecondaryButton`
|
||||
// (0x02) is false — the side button registered only on hover, never while
|
||||
// drawing. `& kSecondaryButton != 0` catches both.
|
||||
if ((event.buttons & kSecondaryButton) != 0 ||
|
||||
event.kind == PointerDeviceKind.invertedStylus) {
|
||||
return true;
|
||||
}
|
||||
@@ -324,20 +333,25 @@ class _PenCanvasState extends State<PenCanvas> {
|
||||
? widget.color.withAlpha(0x80)
|
||||
: widget.color;
|
||||
|
||||
/// Stroke-erase: remove the first committed stroke within proximity of [p].
|
||||
/// Partial (segment) erase: find the first committed stroke the eraser circle
|
||||
/// touches and replace it with its surviving pieces. The eraser radius is in
|
||||
/// normalized page-width fractions; [aspect] corrects the y axis so the circle
|
||||
/// stays round on screen (the page rect is not square).
|
||||
void _eraseAt(PenPoint? p) {
|
||||
if (p == null) return;
|
||||
final radius = widget.strokeWidth * 2; // normalized radius
|
||||
final radius = widget.strokeWidth * 2; // normalized (page-width fraction)
|
||||
final aspect = widget.pageSize.width <= 0
|
||||
? 1.0
|
||||
: widget.pageSize.height / widget.pageSize.width;
|
||||
for (var i = widget.strokes.length - 1; i >= 0; i--) {
|
||||
final stroke = widget.strokes[i];
|
||||
for (final sp in stroke.points) {
|
||||
final dx = sp.x - p.x;
|
||||
final dy = sp.y - p.y;
|
||||
if (dx * dx + dy * dy < radius * radius) {
|
||||
widget.onEraseStroke(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!strokeHit(stroke, p.x, p.y, radius, aspect: aspect)) continue;
|
||||
final pieces =
|
||||
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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -247,7 +247,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_schedulePageSave(_pageIndex, snapshot);
|
||||
}
|
||||
|
||||
void _eraseStroke(int index) {
|
||||
/// Replace committed stroke [index] with its surviving pieces after a partial
|
||||
/// (segment) erase. An empty [replacements] list removes the stroke entirely.
|
||||
void _eraseStroke(int index, List<PenStroke> replacements) {
|
||||
final list = _strokesByPage[_pageIndex];
|
||||
final willMutate = list != null && index >= 0 && index < list.length;
|
||||
if (willMutate) {
|
||||
@@ -256,7 +258,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
}
|
||||
setState(() {
|
||||
if (list != null && index >= 0 && index < list.length) {
|
||||
final next = List<PenStroke>.of(list)..removeAt(index);
|
||||
final next = List<PenStroke>.of(list)
|
||||
..replaceRange(index, index + 1, replacements);
|
||||
_strokesByPage[_pageIndex] = next;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user