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

@@ -16,12 +16,14 @@ import 'package:path/path.dart' as p;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../pdf/slide_export.dart';
import '../ui/pen_settings_page.dart';
import 'editor_tool.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
@@ -52,13 +54,33 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
/// keeps the slide's aspect (no distortion). Null until loaded.
Map<int, Size>? _slideSizes;
CanvasTool _tool = CanvasTool.pen;
/// The single active-tool state (shared model across the 3 editors).
EditorToolKind _tool = EditorToolKind.brush;
/// Selected brush for the PEN tool. Highlighter tool uses the highlighter
/// Selected brush for the BRUSH tool. Highlighter tool uses the highlighter
/// brush; local state only (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
Color _color = Colors.black;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// rnote-style per-brush color memory (see PenNoteScreen). In-memory only.
final Map<BrushKind, Color> _brushColors = {
BrushKind.fountainPen: Colors.black,
BrushKind.ballpoint: Colors.blue,
BrushKind.pencil: Colors.green,
BrushKind.highlighter: Colors.orange,
};
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
bool _allowFingerDrawing = false;
bool _needsCenter = true;
bool _showSlider = false;
@@ -189,6 +211,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
setState(() {
_slideIndex = clamped;
_needsCenter = true;
_selectedStroke = null; // selection is per-slide
});
}
@@ -280,10 +303,41 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
_transform.value = Matrix4.identity()..translateByDouble(o.dx, o.dy, 0, 1);
}
double get _strokeWidth => _tool == CanvasTool.highlighter
double get _strokeWidth => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
// ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ──
void _selectStroke(int? index) {
setState(() => _selectedStroke = index);
}
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
final strokes = _currentStrokes;
if (index < 0 || index >= strokes.length) return;
setState(() {
if (isDragStart) _pushUndo();
final next = List<PenStroke>.from(strokes);
next[index] = translateStroke(next[index], dx, dy);
_strokesBySlide[_slideIndex] = next;
});
}
void _deleteSelected() {
final idx = _selectedStroke;
final strokes = _currentStrokes;
if (idx == null || idx < 0 || idx >= strokes.length) return;
setState(() {
_pushUndo();
_strokesBySlide[_slideIndex] = [
...strokes.sublist(0, idx),
...strokes.sublist(idx + 1),
];
_selectedStroke = null;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -368,10 +422,14 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
pageSize: pageSize,
strokes: _currentStrokes,
transformationController: _transform,
tool: _tool,
tool: editorToolToCanvas(_tool),
brush: _penBrush,
shapeKind: _shapeKind,
color: _color,
strokeWidth: _strokeWidth,
selectedStrokeIndex: _selectedStroke,
onSelectStroke: _selectStroke,
onMoveStroke: _moveStroke,
pressureGamma:
_penConfig?.value.pressureGamma ?? kNaturalPressureGamma,
eraserRadius: _penConfig?.value.eraserRadius ?? kDefaultEraserRadius,
@@ -405,26 +463,51 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen,
active: _tool == EditorToolKind.brush,
tooltip: 'Brush',
labelFor: brushLabelEn,
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) => setState(() {
_penBrush = b;
_tool = CanvasTool.pen;
_tool = EditorToolKind.brush;
}),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter,
selected: _tool == EditorToolKind.highlighter,
tooltip: 'Highlighter',
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
onPressed: () => setState(() => _tool = EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == CanvasTool.eraser,
selected: _tool == EditorToolKind.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = CanvasTool.eraser),
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
),
ToolButton(
icon: Icons.ads_click,
selected: _tool == EditorToolKind.select,
tooltip: 'Select',
onPressed: () => setState(() => _tool = EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _tool == EditorToolKind.shape,
tooltip: 'Shape',
labelFor: shapeLabelEn,
onActivate: () => setState(() => _tool = EditorToolKind.shape),
onSelected: (s) => setState(() {
_shapeKind = s;
_tool = EditorToolKind.shape;
}),
),
if (_tool == EditorToolKind.select && _selectedStroke != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: 'Delete selection',
onPressed: _deleteSelected,
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
@@ -466,12 +549,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
}
Widget _colorDot(Color c, ColorScheme cs) {
final selected =
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
final selected = _color.toARGB32() == c.toARGB32() &&
_tool != EditorToolKind.eraser &&
_tool != EditorToolKind.select;
return GestureDetector(
onTap: () => setState(() {
_color = c;
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
_tool = EditorToolKind.brush;
}
_brushColors[_activeColorBrush] = c;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),