feat(tools): rnote-style toolbar core writing batch
Some checks failed
CI / Windows build (push) Has been cancelled
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user