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

@@ -0,0 +1,53 @@
// lib/editor/canvas/editor_tool.dart
//
// The shared tool model for the pen-first editors (PDF, note, slide). Replaces
// the scattered per-editor booleans (`_selectTextMode`, `_placeLinkMode`, the
// old `CanvasTool` pen/highlighter/eraser triad) with ONE active-tool enum so
// every editor reasons about "which tool is active" the same way.
//
// [EditorToolKind] is the core, render-path-independent set shared by all three
// editors. The PDF editor layers TWO extra page-anchored tools on top
// (select-text and place-scratch-link) that the PenCanvas editors don't have —
// those remain editor-local because they ride pdfrx's text layer / the page
// overlay, not the ink capture path. See `selectTextOrLink` note below.
//
// TODO(toolbar-batch-2): text/typing tool, bookmark-to-paragraph, search+OCR,
// templates, Windows Ink — later batches add kinds here.
/// The shared inking/editing tools available on every pen-first canvas.
enum EditorToolKind {
/// Freehand drawing with the currently-selected [BrushKind] (fountain pen,
/// ballpoint, or pencil). Each brush carries its own remembered color.
brush,
/// Freehand drawing with the highlighter brush (its own color + flat width).
highlighter,
/// Stroke eraser (partial / whole-stroke per PenConfig).
eraser,
/// Cursor / selection tool: tap a committed stroke to select it, drag the
/// selection to translate it, delete to remove it.
select,
/// Shape tool: pen-drag previews a [ShapeKind] from start→current and commits
/// it as a generated [PenStroke] on release.
shape,
}
/// The shapes the [EditorToolKind.shape] tool can draw. Each is generated as a
/// plain [PenStroke] (a polyline) so it reuses stroke rendering, persistence,
/// erase, and undo with no new model — see `shape_geometry.dart`.
enum ShapeKind {
/// Straight line: 2 points (start → end).
line,
/// Axis-aligned rectangle: 5-point closed polyline (start corner → end corner).
rectangle,
/// Ellipse inscribed in the start→end bounding box: ~48 sampled points.
ellipse,
/// Arrow: shaft (start → end) plus two arrowhead segments at the end.
arrow,
}

View File

@@ -199,6 +199,52 @@ class EraserPreviewPainter extends CustomPainter {
old.pageSize != pageSize;
}
/// Paints the SELECT tool's selection: a dashed-ish bounding box around the
/// selected stroke(s) so the user sees what is selected and draggable. The box
/// is given in normalized page coords and scaled to pixels at paint time.
class SelectionOverlayPainter extends CustomPainter {
SelectionOverlayPainter({
required this.boundsNorm,
required this.pageSize,
});
/// Selection bounding box in normalized page coords (null = nothing selected).
final Rect? boundsNorm;
final Size pageSize;
@override
void paint(Canvas canvas, Size size) {
final b = boundsNorm;
if (b == null) return;
// Inflate slightly so the box doesn't clip the stroke's rendered width.
const padPx = 6.0;
final rect = Rect.fromLTRB(
b.left * pageSize.width - padPx,
b.top * pageSize.height - padPx,
b.right * pageSize.width + padPx,
b.bottom * pageSize.height + padPx,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
Paint()
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
Paint()
..color = const Color(0xFF2962FF)
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..isAntiAlias = true,
);
}
@override
bool shouldRepaint(SelectionOverlayPainter old) =>
old.boundsNorm != boundsNorm || old.pageSize != pageSize;
}
/// Paints just the in-progress stroke (the live layer), kept behind its own
/// RepaintBoundary so committed strokes don't repaint on every move.
class LiveInkPainter extends CustomPainter {

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,
),
),
),
),
],
),
),

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;
}

View File

@@ -14,12 +14,14 @@ import '../../models/note.dart';
import '../../providers/note_provider.dart';
import '../../providers/ocr_provider.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 '../notebook/ink_stroke_adapter.dart';
import '../ui/pen_settings_page.dart';
import 'editor_tool.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
@@ -45,14 +47,40 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
final List<List<PenStroke>> _undo = [];
final List<List<PenStroke>> _redo = [];
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 (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 — see 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: 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: the highlighter when the
/// highlighter tool is active, otherwise the selected pen brush.
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
/// The active drawing color (the active brush's remembered color).
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
bool _allowFingerDrawing = false;
bool _dirty = false;
bool _needsCenter = true;
@@ -242,10 +270,46 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
_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 (reuses the undo stacks) ─────────────
/// Set (or clear) the selected stroke from a SELECT-tool tap.
void _selectStroke(int? index) {
setState(() => _selectedStroke = index);
}
/// Translate the selected stroke by ([dx],[dy]) normalized. On the first delta
/// of a drag ([isDragStart]) push ONE undo snapshot so the whole drag is a
/// single undo step.
void _moveStroke(int index, double dx, double dy, bool isDragStart) {
if (index < 0 || index >= _strokes.length) return;
setState(() {
if (isDragStart) _pushUndo();
final next = List<PenStroke>.from(_strokes);
next[index] = translateStroke(next[index], dx, dy);
_strokes = next;
_dirty = true;
});
}
/// Delete the selected stroke (button or long-press), as one undo step.
void _deleteSelected() {
final idx = _selectedStroke;
if (idx == null || idx < 0 || idx >= _strokes.length) return;
setState(() {
_pushUndo();
_strokes = [
..._strokes.sublist(0, idx),
..._strokes.sublist(idx + 1),
];
_selectedStroke = null;
_dirty = true;
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -324,10 +388,14 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
pageSize: pageSize,
strokes: _strokes,
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,
@@ -367,30 +435,57 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Pen tool with brush picker (fountain / ballpoint / pencil).
// Pen tool with brush picker (fountain / ballpoint / pencil), each
// brush showing its own remembered color.
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),
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),
),
// Select (cursor) + shape tools.
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,
@@ -430,12 +525,18 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
}
Widget _colorDot(Color c, ColorScheme cs) {
// Selected against the ACTIVE brush's remembered color. A color tap updates
// only that brush's entry (rnote per-brush color memory).
final selected = _color.toARGB32() == c.toARGB32() &&
_tool != CanvasTool.eraser;
_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),

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../engine/brush.dart';
import 'editor_tool.dart';
/// Localized display name for a brush (single source so all three editors agree).
String brushLabel(BrushKind kind, AppLocalizations l) => switch (kind) {
@@ -28,6 +29,24 @@ String brushLabelEn(BrushKind kind) => switch (kind) {
BrushKind.highlighter => 'Highlighter',
};
/// Localized display name for a shape kind.
String shapeLabel(ShapeKind kind, AppLocalizations l) => switch (kind) {
ShapeKind.line => l.shapeLine,
ShapeKind.rectangle => l.shapeRectangle,
ShapeKind.ellipse => l.shapeEllipse,
ShapeKind.arrow => l.shapeArrow,
};
/// English fallback shape name (for the note/slide editors which use hardcoded
/// English strings — see [brushLabelEn]).
/// TODO(brush-l10n-noteslide): localize the note/slide toolbars wholesale.
String shapeLabelEn(ShapeKind kind) => switch (kind) {
ShapeKind.line => 'Line',
ShapeKind.rectangle => 'Rectangle',
ShapeKind.ellipse => 'Ellipse',
ShapeKind.arrow => 'Arrow',
};
/// The brushes selectable as the PEN tool. The highlighter is its own tool, so
/// it is NOT offered here (eraser is also a separate tool).
const List<BrushKind> kPenToolBrushes = [
@@ -44,11 +63,23 @@ IconData brushIcon(BrushKind kind) => switch (kind) {
BrushKind.highlighter => Icons.brush_outlined, // marker
};
/// Material icon for a [ShapeKind] (used in the shape picker + tool button).
IconData shapeIcon(ShapeKind kind) => switch (kind) {
ShapeKind.line => Icons.show_chart, // straight line
ShapeKind.rectangle => Icons.crop_square,
ShapeKind.ellipse => Icons.circle_outlined,
ShapeKind.arrow => Icons.arrow_outward,
};
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
///
/// Highlighter and eraser remain separate tools. Tapping the button opens a
/// menu of [kPenToolBrushes]; the chosen brush is reported via [onSelected].
/// [labelFor] localizes each brush name so the menu honors the app locale.
/// [colorFor] returns each brush's REMEMBERED color (rnote-style per-brush color
/// memory): the active brush's color is shown as an underline on the button and
/// as a dot beside each menu item, so the toolbar makes each brush's color
/// visible at a glance.
class BrushPickerButton extends StatelessWidget {
const BrushPickerButton({
super.key,
@@ -56,6 +87,7 @@ class BrushPickerButton extends StatelessWidget {
required this.active,
required this.onSelected,
required this.labelFor,
required this.colorFor,
required this.tooltip,
});
@@ -70,6 +102,9 @@ class BrushPickerButton extends StatelessWidget {
/// Localized display name for a brush.
final String Function(BrushKind) labelFor;
/// The remembered color for a brush (drives the underline + menu dots).
final Color Function(BrushKind) colorFor;
final String tooltip;
@override
@@ -91,6 +126,17 @@ class BrushPickerButton extends StatelessWidget {
Icon(brushIcon(b), size: 20),
const SizedBox(width: 10),
Text(labelFor(b)),
const SizedBox(width: 8),
// The brush's remembered color (rnote per-brush color memory).
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: colorFor(b),
shape: BoxShape.circle,
border: Border.all(color: cs.outlineVariant),
),
),
if (b == selected) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
@@ -107,11 +153,26 @@ class BrushPickerButton extends StatelessWidget {
color: active ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(brushIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
],
),
// Per-brush color underline: shows the active brush's remembered
// color so switching brushes visibly switches the color.
Container(
height: 3,
width: 24,
decoration: BoxDecoration(
color: colorFor(selected),
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
@@ -119,6 +180,87 @@ class BrushPickerButton extends StatelessWidget {
}
}
/// A toggle-style tool button that doubles as a [ShapeKind] picker: a short tap
/// activates the shape tool with the current shape; a long-press (or the dropdown
/// caret) opens the line / rectangle / ellipse / arrow submenu.
class ShapePickerButton extends StatelessWidget {
const ShapePickerButton({
super.key,
required this.selected,
required this.active,
required this.onActivate,
required this.onSelected,
required this.labelFor,
required this.tooltip,
});
/// The currently selected shape kind.
final ShapeKind selected;
/// True when the shape tool is the active tool — drives highlight.
final bool active;
/// Called when the button body is tapped (activate the shape tool).
final VoidCallback onActivate;
/// Called when a shape kind is picked from the submenu (also activates).
final ValueChanged<ShapeKind> onSelected;
/// Localized display name for a shape kind.
final String Function(ShapeKind) labelFor;
final String tooltip;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor = active ? cs.onSecondaryContainer : cs.onSurfaceVariant;
return PopupMenuButton<ShapeKind>(
tooltip: tooltip,
initialValue: selected,
onSelected: onSelected,
itemBuilder: (context) => [
for (final s in ShapeKind.values)
PopupMenuItem<ShapeKind>(
value: s,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(shapeIcon(s), size: 20),
const SizedBox(width: 10),
Text(labelFor(s)),
if (s == selected) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onActivate,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: active ? cs.secondaryContainer : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(shapeIcon(selected), size: 22, color: iconColor),
Icon(Icons.arrow_drop_down, size: 18, color: iconColor),
],
),
),
),
);
}
}
/// A Material 3 toggle-style icon button for the floating tool palette.
class ToolButton extends StatelessWidget {
const ToolButton({

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),