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:
53
lib/editor/canvas/editor_tool.dart
Normal file
53
lib/editor/canvas/editor_tool.dart
Normal 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,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
138
lib/editor/engine/shape_geometry.dart
Normal file
138
lib/editor/engine/shape_geometry.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
// lib/editor/engine/shape_geometry.dart
|
||||
//
|
||||
// Pure geometry for the SHAPE tool. Each shape is generated as a list of
|
||||
// NORMALIZED [PenPoint]s (the same model freehand strokes use), so a shape is
|
||||
// just a [PenStroke] — it reuses stroke rendering, persistence, erase, and undo
|
||||
// with NO new model or storage. Points carry a constant pressure (1.0) so the
|
||||
// brush renders them at a steady width (shapes don't taper with pressure).
|
||||
//
|
||||
// All inputs/outputs are in normalized page coordinates ([0,1] x [0,1]); the
|
||||
// caller wraps the points in a PenStroke with the current brush color/width.
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../canvas/editor_tool.dart';
|
||||
import '../canvas/pen_stroke.dart';
|
||||
|
||||
/// Number of points sampled around an ellipse. Kept as a const so tests can pin
|
||||
/// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the
|
||||
/// last point repeats the first ⇒ [kEllipseSamples] + 1 total points.
|
||||
const int kEllipseSamples = 48;
|
||||
|
||||
/// Constant pressure baked into every shape point so the brush renders a steady
|
||||
/// width (no pressure taper for geometric shapes).
|
||||
const double _kShapePressure = 1.0;
|
||||
|
||||
/// Generate the normalized polyline for [kind] spanning [start] → [end].
|
||||
///
|
||||
/// * [ShapeKind.line] → 2 points.
|
||||
/// * [ShapeKind.rectangle] → 5 points (closed: 4 corners + repeat of the
|
||||
/// first), an axis-aligned box whose opposite corners are [start]/[end].
|
||||
/// * [ShapeKind.ellipse] → [kEllipseSamples] + 1 points (closed), inscribed
|
||||
/// in the [start]→[end] bounding box.
|
||||
/// * [ShapeKind.arrow] → shaft (start → end) + two arrowhead segments,
|
||||
/// emitted as a single polyline so it renders as one stroke.
|
||||
List<PenPoint> generateShapePoints(ShapeKind kind, PenPoint start, PenPoint end) {
|
||||
switch (kind) {
|
||||
case ShapeKind.line:
|
||||
return [
|
||||
PenPoint(start.x, start.y, _kShapePressure),
|
||||
PenPoint(end.x, end.y, _kShapePressure),
|
||||
];
|
||||
|
||||
case ShapeKind.rectangle:
|
||||
final l = math.min(start.x, end.x);
|
||||
final r = math.max(start.x, end.x);
|
||||
final t = math.min(start.y, end.y);
|
||||
final b = math.max(start.y, end.y);
|
||||
return [
|
||||
PenPoint(l, t, _kShapePressure),
|
||||
PenPoint(r, t, _kShapePressure),
|
||||
PenPoint(r, b, _kShapePressure),
|
||||
PenPoint(l, b, _kShapePressure),
|
||||
PenPoint(l, t, _kShapePressure), // close
|
||||
];
|
||||
|
||||
case ShapeKind.ellipse:
|
||||
final cx = (start.x + end.x) / 2;
|
||||
final cy = (start.y + end.y) / 2;
|
||||
final rx = (end.x - start.x).abs() / 2;
|
||||
final ry = (end.y - start.y).abs() / 2;
|
||||
final pts = <PenPoint>[];
|
||||
for (var i = 0; i <= kEllipseSamples; i++) {
|
||||
final a = (i / kEllipseSamples) * 2 * math.pi;
|
||||
pts.add(PenPoint(
|
||||
cx + rx * math.cos(a),
|
||||
cy + ry * math.sin(a),
|
||||
_kShapePressure,
|
||||
));
|
||||
}
|
||||
return pts;
|
||||
|
||||
case ShapeKind.arrow:
|
||||
// Shaft start→end, then back up the shaft to draw the two head barbs so
|
||||
// the whole arrow is one continuous polyline (no pen lifts).
|
||||
final dx = end.x - start.x;
|
||||
final dy = end.y - start.y;
|
||||
final len = math.sqrt(dx * dx + dy * dy);
|
||||
final pts = <PenPoint>[
|
||||
PenPoint(start.x, start.y, _kShapePressure),
|
||||
PenPoint(end.x, end.y, _kShapePressure),
|
||||
];
|
||||
if (len <= 1e-6) return pts; // degenerate: just the (near-zero) shaft
|
||||
// Arrowhead: barbs at ±[_kArrowAngle] from the reversed shaft direction,
|
||||
// [_kArrowHead] of the shaft length (capped) long.
|
||||
final ang = math.atan2(dy, dx);
|
||||
final head = math.min(len * _kArrowHeadFraction, _kArrowHeadMax);
|
||||
for (final sign in const [1.0, -1.0]) {
|
||||
final a = ang + math.pi + sign * _kArrowAngle;
|
||||
pts.add(PenPoint(
|
||||
end.x + head * math.cos(a),
|
||||
end.y + head * math.sin(a),
|
||||
_kShapePressure,
|
||||
));
|
||||
pts.add(PenPoint(end.x, end.y, _kShapePressure)); // back to the tip
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrowhead barb length as a fraction of the shaft length.
|
||||
const double _kArrowHeadFraction = 0.25;
|
||||
|
||||
/// Hard cap on the barb length (normalized) so a long arrow's head stays sane.
|
||||
const double _kArrowHeadMax = 0.06;
|
||||
|
||||
/// Half-angle of the arrowhead barbs from the shaft (radians ≈ 28°).
|
||||
const double _kArrowAngle = 0.5;
|
||||
|
||||
/// Return a copy of [points] translated by ([dx],[dy]) in normalized coords,
|
||||
/// preserving pressure/tilt. Used by the SELECT tool to drag a stroke.
|
||||
List<PenPoint> translatePoints(List<PenPoint> points, double dx, double dy) =>
|
||||
[for (final p in points) PenPoint(p.x + dx, p.y + dy, p.pressure, tilt: p.tilt)];
|
||||
|
||||
/// A translated copy of [stroke] (its points shifted by [dx],[dy]); color,
|
||||
/// width, kind, and brush are preserved.
|
||||
PenStroke translateStroke(PenStroke stroke, double dx, double dy) => PenStroke(
|
||||
points: translatePoints(stroke.points, dx, dy),
|
||||
color: stroke.color,
|
||||
width: stroke.width,
|
||||
kind: stroke.kind,
|
||||
brush: stroke.brush,
|
||||
);
|
||||
|
||||
/// Tight normalized bounds of [stroke]'s points, or null when it has no points.
|
||||
/// Used by the SELECT tool to draw the selection bounding box.
|
||||
({double left, double top, double right, double bottom})? penStrokeBounds(
|
||||
PenStroke stroke) {
|
||||
if (stroke.points.isEmpty) return null;
|
||||
var l = double.infinity, t = double.infinity;
|
||||
var r = double.negativeInfinity, b = double.negativeInfinity;
|
||||
for (final p in stroke.points) {
|
||||
if (p.x < l) l = p.x;
|
||||
if (p.y < t) t = p.y;
|
||||
if (p.x > r) r = p.x;
|
||||
if (p.y > b) b = p.y;
|
||||
}
|
||||
return (left: l, top: t, right: r, bottom: b);
|
||||
}
|
||||
Reference in New Issue
Block a user