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

@@ -32,6 +32,7 @@ import '../../models/scratch_link.dart';
import '../../screens/split_view_screen.dart';
import '../../services/database_service.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
@@ -47,9 +48,9 @@ import '../persistence/editor_repository.dart';
import '../persistence/save_scheduler.dart';
import '../ui/pen_settings_page.dart';
import '../ui/thumbnail_grid.dart';
import 'editor_tool.dart';
import 'ink_painters.dart' show buildStrokePath;
import 'input_diagnostics.dart';
import 'pen_canvas.dart' show CanvasTool;
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
import 'pinch_scale_solver.dart';
@@ -198,14 +199,48 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
bool _showPenDebug = false;
double _peakNorm = 0;
// Tool state.
CanvasTool _tool = CanvasTool.pen;
// Tool state. The single shared active-tool enum; the page-anchored
// select-text / place-link tools (below) are PDF-only and ride a different
// path (they disable pen capture), so they stay as their own booleans.
EditorToolKind _tool = EditorToolKind.brush;
/// Selected brush for the PEN tool (fountain/ballpoint/pencil). The
/// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
/// highlighter tool always uses [BrushKind.highlighter]. Local state only for
/// this increment (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// SELECT tool: ([page], strokeIndex) of the selected committed stroke, or
/// null. Selection is per-page (each page has its own stroke list).
({int page, int index})? _selected;
/// SHAPE tool: normalized start point + its page, while a shape drag is live.
({int page, Offset start})? _shapeDrag;
/// SELECT tool: last normalized drag position + page, to compute the
/// incremental translation; and whether the drag's undo snapshot was taken.
Offset? _selectLast;
bool _selectDragging = false;
/// rnote-style per-brush color memory: each brush (and the highlighter)
/// remembers its own color. Selecting a brush restores its color; picking a
/// color updates ONLY the active brush's entry. In-memory only for this
/// increment (TODO(brush-color-persist)).
final Map<BrushKind, Color> _brushColors = {
BrushKind.fountainPen: Colors.black,
BrushKind.ballpoint: Colors.blue,
BrushKind.pencil: Colors.green,
BrushKind.highlighter: Colors.orange,
};
/// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter,
/// else the selected pen brush).
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
/// When true the "select text" tool is active: pen capture is disabled so the
/// pen falls through to pdfrx for native text selection.
bool _selectTextMode = false;
@@ -221,7 +256,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
static const _uuid = Uuid();
Color _color = Colors.black;
/// The active drawing color = the active brush's remembered color.
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
bool _allowFingerDrawing = false;
/// Whether the viewer currently has a non-empty text selection (drives the
@@ -240,13 +276,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
Colors.orange,
];
/// True when a PEN tool (pen/highlighter/eraser) is active — pen capture is on.
/// False in select-text mode (pen reaches pdfrx text selection) and in
/// place-link mode (a tap drops an anchor via the page overlay).
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
/// selection) and in place-link mode (a tap drops an anchor via the overlay).
bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode;
/// True when the eraser tool is active.
bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode;
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
/// True when the SELECT tool is active (and not in a page-anchored mode).
bool get _isSelect =>
_tool == EditorToolKind.select && _penCaptureEnabled;
/// True when the SHAPE tool is active.
bool get _isShape => _tool == EditorToolKind.shape && _penCaptureEnabled;
@override
void initState() {
@@ -500,6 +543,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_eraseAt(hit.page, hit.normalized);
return;
}
if (_isSelect) {
_liveStrokePage = hit.page;
_selectLast = hit.normalized;
_selectDragging = false;
_selectAt(hit.page, hit.normalized);
return;
}
if (_isShape) {
_liveStrokePage = hit.page;
_shapeDrag = (page: hit.page, start: hit.normalized);
return;
}
_liveStrokePage = hit.page;
_livePoints
..clear()
@@ -516,6 +571,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
return;
}
if (_isSelect) {
if (hit == null || hit.page != page) return;
_dragSelected(page, hit.normalized);
return;
}
if (_isShape) {
if (hit == null || hit.page != page) return;
_updateShapePreview(page, hit.normalized);
return;
}
// A stroke belongs to ONE page: ignore samples on a different page.
if (hit == null || hit.page != page) return;
_livePoints.add(PenPoint(
@@ -526,6 +591,100 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
}
/// SELECT down: hit-test the page's committed strokes (topmost first) and set
/// the selection (or clear it on empty space).
void _selectAt(int page, Offset normalized) {
final strokes = _strokesByPage[page];
final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
final aspect = _pageAspect(page);
int? hitIndex;
if (strokes != null) {
for (var i = strokes.length - 1; i >= 0; i--) {
if (strokeHit(strokes[i], normalized.dx, normalized.dy, radius,
aspect: aspect)) {
hitIndex = i;
break;
}
}
}
setState(() {
_selected = hitIndex == null ? null : (page: page, index: hitIndex);
});
_bumpOverlay();
}
/// SELECT drag: translate the selected stroke by the incremental delta,
/// recording ONE undo snapshot on the first delta of the drag.
void _dragSelected(int page, Offset normalized) {
final sel = _selected;
final last = _selectLast;
if (sel == null || last == null || sel.page != page) {
_selectLast = normalized;
return;
}
final dx = normalized.dx - last.dx;
final dy = normalized.dy - last.dy;
_selectLast = normalized;
if (dx == 0 && dy == 0) return;
_moveSelected(dx, dy, isDragStart: !_selectDragging);
_selectDragging = true;
}
/// Translate the selected stroke by ([dx],[dy]); persists + (on [isDragStart])
/// records one undo snapshot via the existing per-page undo stack.
void _moveSelected(double dx, double dy, {required bool isDragStart}) {
final sel = _selected;
if (sel == null) return;
final list = _strokesByPage[sel.page];
if (list == null || sel.index < 0 || sel.index >= list.length) return;
if (isDragStart) _undoFor(sel.page).record(List<PenStroke>.of(list));
setState(() {
final next = List<PenStroke>.of(list);
next[sel.index] = translateStroke(next[sel.index], dx, dy);
_strokesByPage[sel.page] = next;
});
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
_bumpOverlay();
}
/// Delete the selected stroke (button or long-press) as one undo step.
void _deleteSelected() {
final sel = _selected;
if (sel == null) return;
final list = _strokesByPage[sel.page];
if (list == null || sel.index < 0 || sel.index >= list.length) return;
_undoFor(sel.page).record(List<PenStroke>.of(list));
setState(() {
final next = List<PenStroke>.of(list)..removeAt(sel.index);
_strokesByPage[sel.page] = next;
_selected = null;
});
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
_bumpOverlay();
}
/// SHAPE preview: rebuild the generated shape stroke from start→current and
/// publish it as the live stroke (drawn by the page overlay painter).
void _updateShapePreview(int page, Offset current) {
final drag = _shapeDrag;
if (drag == null || drag.page != page) return;
final pts = generateShapePoints(
_shapeKind,
PenPoint(drag.start.dx, drag.start.dy, 1.0),
PenPoint(current.dx, current.dy, 1.0),
);
_liveStrokeVN.value = _LiveStrokeData(
page,
PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: PenStrokeKind.pen,
brush: _currentBrush(),
),
);
}
void _updateLiveStroke() {
final page = _liveStrokePage;
if (page == null || _livePoints.isEmpty) return;
@@ -543,7 +702,19 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _endStroke({required bool commit}) {
final page = _liveStrokePage;
if (page != null && commit && !_isEraser && _livePoints.isNotEmpty) {
// SHAPE: on release, commit the generated shape stroke.
final drag = _shapeDrag;
if (_isShape && drag != null && commit) {
final end = _liveStrokeVN.value;
if (end != null && end.page == drag.page) {
_commitStroke(drag.page, end.stroke);
}
} else if (page != null &&
commit &&
!_isEraser &&
!_isSelect &&
!_isShape &&
_livePoints.isNotEmpty) {
// A single tap → tiny dot is allowed (perfect_freehand renders a dot for
// a 1-point stroke).
_commitStroke(
@@ -558,6 +729,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
);
}
_liveStrokePage = null;
_shapeDrag = null;
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
_liveStrokeVN.value = null;
_bumpOverlay();
@@ -600,21 +774,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus;
double _currentStrokeWidth() => _tool == CanvasTool.highlighter
double _currentStrokeWidth() => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
PenStrokeKind _currentKind() => _tool == CanvasTool.highlighter
PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen;
/// Brush in effect: highlighter tool ⇒ highlighter brush, else the selected
/// pen brush. Drives both the capture-time pressure warp and render geometry.
BrushKind _currentBrush() => _tool == CanvasTool.highlighter
BrushKind _currentBrush() => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
Color _currentColor() => _tool == CanvasTool.highlighter
Color _currentColor() => _tool == EditorToolKind.highlighter
? _color.withAlpha(0x80)
: _color;
@@ -757,11 +931,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_controller.goToPage(pageNumber: clamped + 1);
}
void _setTool(CanvasTool tool) {
void _setTool(EditorToolKind tool) {
setState(() {
_tool = tool;
_selectTextMode = false;
_placeLinkMode = false;
if (tool != EditorToolKind.select) _selected = null;
});
}
@@ -769,6 +944,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
setState(() {
_selectTextMode = true;
_placeLinkMode = false;
_selected = null;
});
}
@@ -966,6 +1142,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
pageIndex: pageIndex,
strokes: _strokesByPage[pageIndex] ?? const [],
highlights: _highlightsByPage[pageIndex] ?? const [],
selectedIndex:
(_selected != null && _selected!.page == pageIndex)
? _selected!.index
: null,
pageSize: pageRectInViewer.size,
thinning: _penConfig?.value.pressureSensitivity ??
kDefaultPenThinning,
@@ -1058,26 +1238,51 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen && !_selectTextMode,
active: _tool == EditorToolKind.brush && _penCaptureEnabled,
tooltip: l.brushPicker,
labelFor: (b) => brushLabel(b, l),
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) {
setState(() => _penBrush = b);
_setTool(CanvasTool.pen);
_setTool(EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter && !_selectTextMode,
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
tooltip: l.toolHighlighter,
onPressed: () => _setTool(CanvasTool.highlighter),
onPressed: () => _setTool(EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _isEraser,
tooltip: l.toolEraser,
onPressed: () => _setTool(CanvasTool.eraser),
onPressed: () => _setTool(EditorToolKind.eraser),
),
ToolButton(
icon: Icons.ads_click,
selected: _isSelect,
tooltip: l.toolSelect,
onPressed: () => _setTool(EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _isShape,
tooltip: l.shapePicker,
labelFor: (s) => shapeLabel(s, l),
onActivate: () => _setTool(EditorToolKind.shape),
onSelected: (s) {
setState(() => _shapeKind = s);
_setTool(EditorToolKind.shape);
},
),
if (_isSelect && _selected != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: l.actionDeleteSelection,
onPressed: _deleteSelected,
),
PaletteDivider(cs: cs),
// Text selection + highlight (real vector text).
ToolButton(
@@ -1158,9 +1363,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected = _color == c;
// Selected against the ACTIVE brush's remembered color; a tap updates only
// that brush's entry (rnote per-brush color memory). Inert in select mode.
final selected = _color == c && !_isSelect;
return GestureDetector(
onTap: () => setState(() => _color = c),
onTap: () => setState(() => _brushColors[_activeColorBrush] = c),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 28,
@@ -1367,6 +1574,7 @@ class _PageOverlayPainter extends CustomPainter {
required this.highlights,
required this.pageSize,
required this.thinning,
this.selectedIndex,
}) : super(repaint: Listenable.merge([overlayRepaint, liveStrokeVN]));
/// Live stroke source, read at paint time. Only painted when its page matches
@@ -1378,6 +1586,10 @@ class _PageOverlayPainter extends CustomPainter {
final Size pageSize;
final double thinning;
/// SELECT tool: index into [strokes] of the selected stroke on this page, or
/// null. Drives the selection bounding-box overlay.
final int? selectedIndex;
@override
void paint(Canvas canvas, Size size) {
// 1. Text highlights (semi-transparent yellow), normalized → pixels.
@@ -1427,6 +1639,36 @@ class _PageOverlayPainter extends CustomPainter {
);
}
}
// 4. SELECT bounding box around the selected stroke (over everything).
final si = selectedIndex;
if (si != null && si >= 0 && si < strokes.length) {
final b = penStrokeBounds(strokes[si]);
if (b != null) {
const padPx = 6.0;
final rect = Rect.fromLTRB(
b.left * size.width - padPx,
b.top * size.height - padPx,
b.right * size.width + padPx,
b.bottom * size.height + padPx,
);
final rr = RRect.fromRectAndRadius(rect, const Radius.circular(4));
canvas.drawRRect(
rr,
Paint()
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
rr,
Paint()
..color = const Color(0xFF2962FF)
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..isAntiAlias = true,
);
}
}
}
@override
@@ -1437,6 +1679,7 @@ class _PageOverlayPainter extends CustomPainter {
old.strokes.length != strokes.length ||
!identical(old.highlights, highlights) ||
old.highlights.length != highlights.length ||
old.selectedIndex != selectedIndex ||
old.pageSize != pageSize ||
old.thinning != thinning;
}