diff --git a/lib/features/sheet_viewer/drawing/drawing_controller.dart b/lib/features/sheet_viewer/drawing/drawing_controller.dart index a7e7286..c5ee502 100644 --- a/lib/features/sheet_viewer/drawing/drawing_controller.dart +++ b/lib/features/sheet_viewer/drawing/drawing_controller.dart @@ -5,6 +5,23 @@ import 'package:flutter/material.dart'; import 'drawing_line.dart'; import 'paint_preset.dart'; +/// Represents an undoable action. +sealed class DrawingAction { + const DrawingAction(); +} + +/// Action for adding a line. +class AddLineAction extends DrawingAction { + final DrawingLine line; + const AddLineAction(this.line); +} + +/// Action for erasing lines. +class EraseAction extends DrawingAction { + final List erasedLines; + const EraseAction(this.erasedLines); +} + /// Controller for managing drawing state with undo/redo support. /// /// Manages a stack of [DrawingLine] objects and provides methods for @@ -13,8 +30,11 @@ class DrawingController extends ChangeNotifier { /// All completed lines in the drawing final List _lines = []; - /// Lines that have been undone (for redo functionality) - final List _undoneLines = []; + /// Stack of actions for undo functionality + final List _undoStack = []; + + /// Stack of actions for redo functionality + final List _redoStack = []; /// The line currently being drawn (null when not drawing) DrawingLine? _currentLine; @@ -22,6 +42,12 @@ class DrawingController extends ChangeNotifier { /// Current paint preset being used PaintPreset _currentPreset = PaintPreset.blackPen; + /// Whether eraser mode is active + bool _isEraserMode = false; + + /// Lines erased in the current eraser stroke (for undo as single action) + final List _currentErasedLines = []; + /// Maximum number of history steps to keep final int maxHistorySteps; @@ -40,11 +66,14 @@ class DrawingController extends ChangeNotifier { /// Current paint preset PaintPreset get currentPreset => _currentPreset; + /// Whether eraser mode is active + bool get isEraserMode => _isEraserMode; + /// Whether undo is available - bool get canUndo => _lines.isNotEmpty; + bool get canUndo => _undoStack.isNotEmpty; /// Whether redo is available - bool get canRedo => _undoneLines.isNotEmpty; + bool get canRedo => _redoStack.isNotEmpty; // --------------------------------------------------------------------------- // Drawing Operations @@ -52,6 +81,12 @@ class DrawingController extends ChangeNotifier { /// Starts a new line at the given normalized position. void startLine(Offset normalizedPoint) { + if (_isEraserMode) { + _currentErasedLines.clear(); + _eraseAtPoint(normalizedPoint); + return; + } + _currentLine = DrawingLine( points: [normalizedPoint], color: _currentPreset.color, @@ -62,23 +97,41 @@ class DrawingController extends ChangeNotifier { /// Adds a point to the current line. void addPoint(Offset normalizedPoint) { + if (_isEraserMode) { + _eraseAtPoint(normalizedPoint); + return; + } + if (_currentLine == null) return; - if (!currentLine!.isPointTooClose(normalizedPoint)) { - _currentLine = _currentLine!.addPoint(normalizedPoint); - notifyListeners(); - } + // Filter points that are too close to reduce memory usage + if (_currentLine!.isPointTooClose(normalizedPoint)) return; + + _currentLine = _currentLine!.addPoint(normalizedPoint); + notifyListeners(); } /// Completes the current line and adds it to the history. void endLine() { + if (_isEraserMode) { + // If we erased lines in this stroke, record as single action + if (_currentErasedLines.isNotEmpty) { + _undoStack.add(EraseAction(List.from(_currentErasedLines))); + _redoStack.clear(); + _trimHistory(); + _currentErasedLines.clear(); + notifyListeners(); // Update UI to enable undo button + } + return; + } + if (_currentLine == null) return; // Only add lines with at least 2 points if (_currentLine!.points.length >= 2) { _lines.add(_currentLine!); - // Clear redo stack when new action is performed - _undoneLines.clear(); + _undoStack.add(AddLineAction(_currentLine!)); + _redoStack.clear(); _trimHistory(); } @@ -88,8 +141,98 @@ class DrawingController extends ChangeNotifier { /// Trims history to maxHistorySteps to prevent memory growth. void _trimHistory() { - while (_lines.length > maxHistorySteps) { - _lines.removeAt(0); + while (_undoStack.length > maxHistorySteps) { + _undoStack.removeAt(0); + } + } + + // --------------------------------------------------------------------------- + // Eraser Operations + // --------------------------------------------------------------------------- + + /// Eraser hit radius in normalized coordinates + static const double _eraserRadius = 0.015; + + /// Checks if a point is near a line and erases it if so. + void _eraseAtPoint(Offset point) { + // Find all lines that intersect with the eraser point + final linesToRemove = []; + + for (final line in _lines) { + if (_lineIntersectsPoint( + line, point, _eraserRadius + line.strokeWidth / 2)) { + linesToRemove.add(line); + } + } + + // Remove intersecting lines and track them for undo + for (final line in linesToRemove) { + _lines.remove(line); + _currentErasedLines.add(line); + } + + if (linesToRemove.isNotEmpty) { + notifyListeners(); + } + } + + /// Checks if a line intersects with a circular point. + bool _lineIntersectsPoint(DrawingLine line, Offset point, double radius) { + // Check if any point is within radius + for (final linePoint in line.points) { + if ((linePoint - point).distance <= radius) { + return true; + } + } + // Check line segments + for (int i = 0; i < line.points.length - 1; i++) { + if (_pointToSegmentDistance(point, line.points[i], line.points[i + 1]) <= + radius) { + return true; + } + } + return false; + } + + /// Calculates the distance from a point to a line segment. + double _pointToSegmentDistance(Offset point, Offset segStart, Offset segEnd) { + final dx = segEnd.dx - segStart.dx; + final dy = segEnd.dy - segStart.dy; + final lengthSquared = dx * dx + dy * dy; + + if (lengthSquared == 0) { + // Segment is a point + return (point - segStart).distance; + } + + // Project point onto the line, clamping to segment + var t = ((point.dx - segStart.dx) * dx + (point.dy - segStart.dy) * dy) / + lengthSquared; + t = t.clamp(0.0, 1.0); + + final projection = Offset( + segStart.dx + t * dx, + segStart.dy + t * dy, + ); + + return (point - projection).distance; + } + + // --------------------------------------------------------------------------- + // Eraser Mode + // --------------------------------------------------------------------------- + + /// Toggles eraser mode. + void toggleEraserMode() { + _isEraserMode = !_isEraserMode; + notifyListeners(); + } + + /// Sets eraser mode. + void setEraserMode(bool enabled) { + if (_isEraserMode != enabled) { + _isEraserMode = enabled; + notifyListeners(); } } @@ -97,29 +240,55 @@ class DrawingController extends ChangeNotifier { // Undo/Redo // --------------------------------------------------------------------------- - /// Undoes the last line drawn. + /// Undoes the last action. void undo() { if (!canUndo) return; - final line = _lines.removeLast(); - _undoneLines.add(line); + final action = _undoStack.removeLast(); + + switch (action) { + case AddLineAction(:final line): + // Remove the line that was added + _lines.remove(line); + _redoStack.add(action); + case EraseAction(:final erasedLines): + // Restore the lines that were erased + _lines.addAll(erasedLines); + _redoStack.add(action); + } + notifyListeners(); } - /// Redoes the last undone line. + /// Redoes the last undone action. void redo() { if (!canRedo) return; - final line = _undoneLines.removeLast(); - _lines.add(line); + final action = _redoStack.removeLast(); + + switch (action) { + case AddLineAction(:final line): + // Re-add the line + _lines.add(line); + _undoStack.add(action); + case EraseAction(:final erasedLines): + // Re-erase the lines + for (final line in erasedLines) { + _lines.remove(line); + } + _undoStack.add(action); + } + notifyListeners(); } /// Clears all lines from the canvas. void clear() { _lines.clear(); - _undoneLines.clear(); + _undoStack.clear(); + _redoStack.clear(); _currentLine = null; + _currentErasedLines.clear(); notifyListeners(); } @@ -127,9 +296,10 @@ class DrawingController extends ChangeNotifier { // Paint Preset // --------------------------------------------------------------------------- - /// Sets the current paint preset. + /// Sets the current paint preset and disables eraser mode. void setPreset(PaintPreset preset) { _currentPreset = preset; + _isEraserMode = false; notifyListeners(); } @@ -150,8 +320,10 @@ class DrawingController extends ChangeNotifier { /// Imports lines from a JSON-serializable list. void fromJsonList(List> jsonList) { _lines.clear(); - _undoneLines.clear(); + _undoStack.clear(); + _redoStack.clear(); _currentLine = null; + _currentErasedLines.clear(); for (final json in jsonList) { _lines.add(DrawingLine.fromJson(json)); @@ -185,7 +357,8 @@ class DrawingController extends ChangeNotifier { @override void dispose() { _lines.clear(); - _undoneLines.clear(); + _undoStack.clear(); + _redoStack.clear(); super.dispose(); } } diff --git a/lib/features/sheet_viewer/drawing/drawing_toolbar.dart b/lib/features/sheet_viewer/drawing/drawing_toolbar.dart index 802e05a..2e11794 100644 --- a/lib/features/sheet_viewer/drawing/drawing_toolbar.dart +++ b/lib/features/sheet_viewer/drawing/drawing_toolbar.dart @@ -43,13 +43,24 @@ class DrawingToolbar extends StatelessWidget { ...PaintPreset.quickAccess.map((preset) => _buildPresetButton( context, preset, - isSelected: controller.currentPreset == preset, + isSelected: !controller.isEraserMode && + controller.currentPreset == preset, )), const SizedBox(width: 8), _buildDivider(context), const SizedBox(width: 8), + // Eraser button + _buildEraserButton( + context, + isSelected: controller.isEraserMode, + ), + + const SizedBox(width: 8), + _buildDivider(context), + const SizedBox(width: 8), + // Undo button _buildActionButton( context, @@ -127,6 +138,37 @@ class DrawingToolbar extends StatelessWidget { ); } + Widget _buildEraserButton( + BuildContext context, { + required bool isSelected, + }) { + final colorScheme = Theme.of(context).colorScheme; + + return Tooltip( + message: 'Eraser', + child: InkWell( + onTap: () => controller.setEraserMode(true), + borderRadius: BorderRadius.circular(20), + child: Container( + width: 36, + height: 36, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: isSelected + ? Border.all(color: colorScheme.primary, width: 2) + : null, + ), + child: Icon( + Icons.auto_fix_high, + size: 20, + color: colorScheme.onSurface, + ), + ), + ), + ); + } + Widget _buildActionButton( BuildContext context, { required IconData icon,