feat(tools): rnote-style toolbar core writing batch
Some checks failed
CI / Windows build (push) Has been cancelled

Replace the ad-hoc tool palette with a shared tool system
(EditorToolKind) across the PDF, note and slide editors, and add
the core writing tools.

- Multiple brushes, each remembering its OWN color (rnote-style):
  selecting a brush restores its color, changing color updates only
  that brush, and each brush button shows its current color.
- Select tool: tap-select a committed stroke, drag to move it,
  delete it — persisted and undoable.
- Shape tool: line / rectangle / ellipse / arrow, drawn with a live
  preview and committed as generated PenStrokes (shape_geometry.dart)
  so they reuse stroke rendering, erase, persistence and undo.
- Highlighter + eraser fold into the same tool system.

Text/bookmark/search+OCR/backgrounds/Windows-Ink are later batches
(TODO). Brush opacity still deferred. analyze clean, 302 tests.
This commit is contained in:
2026-06-24 20:38:18 +08:00
parent fd102b5703
commit 875dabcd89
18 changed files with 2104 additions and 62 deletions

View File

@@ -30,15 +30,31 @@ import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart';
import '../input/pressure_curve.dart';
import '../input/pen_input_service.dart';
import '../engine/shape_geometry.dart';
import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
import '../render/static_ink_painter.dart' as render;
import 'ink_painters.dart' show EraserPreviewPainter;
import 'editor_tool.dart';
import 'ink_painters.dart' show EraserPreviewPainter, SelectionOverlayPainter;
import 'pen_interactive_viewer.dart';
import 'pen_stroke.dart';
/// The active tool on the pen canvas.
enum CanvasTool { pen, highlighter, eraser }
/// The active tool on the pen canvas. Pen/highlighter/eraser are the legacy
/// triad; [select] and [shape] are the core-writing-batch additions. This mirrors
/// the shared [EditorToolKind] (the PDF editor uses that enum directly); the
/// PenCanvas keeps its own enum because it predates the shared model and is wired
/// through many call sites — see [editorToolToCanvas].
enum CanvasTool { pen, highlighter, eraser, select, shape }
/// Map the shared [EditorToolKind] to the PenCanvas's [CanvasTool] so the note/
/// slide editors can drive PenCanvas from the shared active-tool state.
CanvasTool editorToolToCanvas(EditorToolKind kind) => switch (kind) {
EditorToolKind.brush => CanvasTool.pen,
EditorToolKind.highlighter => CanvasTool.highlighter,
EditorToolKind.eraser => CanvasTool.eraser,
EditorToolKind.select => CanvasTool.select,
EditorToolKind.shape => CanvasTool.shape,
};
class PenCanvas extends StatefulWidget {
const PenCanvas({
@@ -49,10 +65,14 @@ class PenCanvas extends StatefulWidget {
required this.transformationController,
required this.tool,
this.brush = BrushKind.fountainPen,
this.shapeKind = ShapeKind.line,
required this.color,
required this.strokeWidth,
required this.onStrokeComplete,
required this.onEraseStroke,
this.selectedStrokeIndex,
this.onSelectStroke,
this.onMoveStroke,
this.allowFingerDrawing = false,
this.minScale = 0.5,
this.maxScale = 8.0,
@@ -93,6 +113,10 @@ class PenCanvas extends StatefulWidget {
/// pre-warp ([BrushProfile.pressureGamma]) and the render geometry.
final BrushKind brush;
/// The shape to draw when [tool] is [CanvasTool.shape]. Generated as a
/// PenStroke via [generateShapePoints] (no new model).
final ShapeKind shapeKind;
final Color color;
/// Pen width as a fraction of page width (so it zooms with the page).
@@ -107,6 +131,22 @@ class PenCanvas extends StatefulWidget {
final void Function(int strokeIndex, List<PenStroke> replacements)
onEraseStroke;
/// Index of the currently selected committed stroke (SELECT tool), or null.
/// Drives the selection bounding-box overlay.
final int? selectedStrokeIndex;
/// Called when the SELECT tool taps a committed stroke (its index), or null
/// when the tap hits empty space (clears the selection).
final ValueChanged<int?>? onSelectStroke;
/// Called when the SELECT tool drags the selected stroke: ([strokeIndex],
/// [dx],[dy]) is the normalized translation to apply, and [isDragStart] is true
/// on the FIRST delta of a drag so the parent records ONE undo snapshot per
/// drag (not per pixel). The parent translates + persists (see
/// `translateStroke`).
final void Function(int strokeIndex, double dx, double dy, bool isDragStart)?
onMoveStroke;
/// User toggle: allow a single finger to draw. Forced off once a stylus is
/// seen (palm rejection).
final bool allowFingerDrawing;
@@ -169,6 +209,17 @@ class _PenCanvasState extends State<PenCanvas> {
/// Live stroke snapshot handed to the LiveInkPainter; null when idle.
PenStroke? _liveStroke;
/// SHAPE tool: the normalized start point of the in-progress shape, or null.
PenPoint? _shapeStart;
/// SELECT tool: the last normalized drag position, used to compute the
/// incremental translation reported to [PenCanvas.onMoveStroke].
PenPoint? _selectLast;
/// SELECT tool: true once a drag of the selected stroke has begun (so the move
/// undo snapshot is recorded once, on the first drag delta — see _extendStroke).
bool _selectDragging = false;
/// True when the active stylus reports the eraser signal (barrel button or
/// inverted stylus), detected on hover/down.
bool _eraserActive = false;
@@ -211,6 +262,16 @@ class _PenCanvasState extends State<PenCanvas> {
? 1.0
: widget.pageSize.height / widget.pageSize.width;
/// Normalized bounding box of the currently selected stroke (SELECT tool), or
/// null when nothing valid is selected.
Rect? get _selectionBounds {
final idx = widget.selectedStrokeIndex;
if (idx == null || idx < 0 || idx >= widget.strokes.length) return null;
final b = penStrokeBounds(widget.strokes[idx]);
if (b == null) return null;
return Rect.fromLTRB(b.left, b.top, b.right, b.bottom);
}
// 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
@@ -371,9 +432,11 @@ class _PenCanvasState extends State<PenCanvas> {
void _startStroke(PointerDownEvent event) {
_drawPointer = event.pointer;
_livePoints.clear();
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
final p = _toNormalized(event.position, _normalizedPressure(event),
tilt: _tiltFor(event));
if (p != null) _livePoints.add(p);
if (_eraserActive || widget.tool == CanvasTool.eraser) {
_eraserCursor.value = p;
@@ -384,6 +447,24 @@ class _PenCanvasState extends State<PenCanvas> {
if (_liveStroke != null) setState(() => _liveStroke = null);
return;
}
// SELECT: tap hit-tests the committed strokes (topmost first) and reports
// the selection. A subsequent drag translates it (see _extendStroke).
if (widget.tool == CanvasTool.select) {
if (p != null) {
_selectLast = p;
widget.onSelectStroke?.call(_hitTestStroke(p));
}
return;
}
// SHAPE: record the start point; the preview shape is built on each move.
if (widget.tool == CanvasTool.shape) {
_shapeStart = p;
return;
}
if (p != null) _livePoints.add(p);
_updateLiveStroke();
}
@@ -397,14 +478,56 @@ class _PenCanvasState extends State<PenCanvas> {
_eraseAt(p);
return;
}
// SELECT drag: translate the selected stroke by the incremental delta.
if (widget.tool == CanvasTool.select) {
final last = _selectLast;
final idx = widget.selectedStrokeIndex;
if (last != null && idx != null) {
final dx = p.x - last.x;
final dy = p.y - last.y;
if (dx != 0 || dy != 0) {
final isStart = !_selectDragging;
_selectDragging = true;
widget.onMoveStroke?.call(idx, dx, dy, isStart);
}
}
_selectLast = p;
return;
}
// SHAPE preview: regenerate the shape from start→current on every move.
if (widget.tool == CanvasTool.shape) {
_updateShapePreview(p);
return;
}
_livePoints.add(p);
_updateLiveStroke();
}
void _endStroke() {
if (_drawPointer == null) return;
final wasEraser = _eraserActive || widget.tool == CanvasTool.eraser;
if (!wasEraser && _livePoints.isNotEmpty) {
final tool = widget.tool;
final wasEraser = _eraserActive || tool == CanvasTool.eraser;
if (tool == CanvasTool.shape) {
// Commit the generated shape stroke (if the drag spanned any distance).
final start = _shapeStart;
final end = _livePoints.isNotEmpty ? _livePoints.last : null;
if (start != null && end != null) {
final pts = generateShapePoints(widget.shapeKind, start, end);
widget.onStrokeComplete(PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: PenStrokeKind.pen,
brush: _currentBrush,
));
}
} else if (tool == CanvasTool.select) {
// Nothing to commit on release: selection + moves were applied live.
} else if (!wasEraser && _livePoints.isNotEmpty) {
widget.onStrokeComplete(
PenStroke(
points: List.of(_livePoints),
@@ -416,11 +539,47 @@ class _PenCanvasState extends State<PenCanvas> {
);
}
_drawPointer = null;
_shapeStart = null;
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
_eraserCursor.value = null; // hide the preview when the pen lifts
setState(() => _liveStroke = null);
}
/// Hit-test committed strokes (topmost first) at normalized [p]; returns the
/// index of the first stroke within the eraser radius, or null. Reuses
/// [strokeHit] so tap-select matches the eraser's proximity model.
int? _hitTestStroke(PenPoint p) {
final radius = widget.eraserRadius;
final aspect = _pageAspect;
for (var i = widget.strokes.length - 1; i >= 0; i--) {
if (strokeHit(widget.strokes[i], p.x, p.y, radius, aspect: aspect)) {
return i;
}
}
return null;
}
/// Build the SHAPE preview stroke from the recorded start to the current [p].
void _updateShapePreview(PenPoint p) {
final start = _shapeStart;
if (start == null) return;
_livePoints
..clear()
..add(p); // remember the latest end point for commit
final pts = generateShapePoints(widget.shapeKind, start, p);
setState(() {
_liveStroke = PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: widget.strokeWidth,
kind: PenStrokeKind.pen,
brush: _currentBrush,
);
});
}
/// Discard the in-progress stroke without committing (palm/2nd-finger).
void _cancelStroke() {
_drawPointer = null;
@@ -634,7 +793,8 @@ class _PenCanvasState extends State<PenCanvas> {
),
),
),
// Live ink (current stroke only, isolated repaint).
// Live ink (current stroke only, isolated repaint). Also carries
// the SHAPE tool's preview (built as a live PenStroke).
Positioned.fill(
child: RepaintBoundary(
child: CustomPaint(
@@ -646,6 +806,18 @@ class _PenCanvasState extends State<PenCanvas> {
),
),
),
// SELECT tool: bounding box around the selected stroke.
if (widget.tool == CanvasTool.select && _selectionBounds != null)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: SelectionOverlayPainter(
boundsNorm: _selectionBounds,
pageSize: widget.pageSize,
),
),
),
),
],
),
),