Add eraser
This commit is contained in:
@@ -5,6 +5,23 @@ import 'package:flutter/material.dart';
|
|||||||
import 'drawing_line.dart';
|
import 'drawing_line.dart';
|
||||||
import 'paint_preset.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<DrawingLine> erasedLines;
|
||||||
|
const EraseAction(this.erasedLines);
|
||||||
|
}
|
||||||
|
|
||||||
/// Controller for managing drawing state with undo/redo support.
|
/// Controller for managing drawing state with undo/redo support.
|
||||||
///
|
///
|
||||||
/// Manages a stack of [DrawingLine] objects and provides methods for
|
/// Manages a stack of [DrawingLine] objects and provides methods for
|
||||||
@@ -13,8 +30,11 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// All completed lines in the drawing
|
/// All completed lines in the drawing
|
||||||
final List<DrawingLine> _lines = [];
|
final List<DrawingLine> _lines = [];
|
||||||
|
|
||||||
/// Lines that have been undone (for redo functionality)
|
/// Stack of actions for undo functionality
|
||||||
final List<DrawingLine> _undoneLines = [];
|
final List<DrawingAction> _undoStack = [];
|
||||||
|
|
||||||
|
/// Stack of actions for redo functionality
|
||||||
|
final List<DrawingAction> _redoStack = [];
|
||||||
|
|
||||||
/// The line currently being drawn (null when not drawing)
|
/// The line currently being drawn (null when not drawing)
|
||||||
DrawingLine? _currentLine;
|
DrawingLine? _currentLine;
|
||||||
@@ -22,6 +42,12 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// Current paint preset being used
|
/// Current paint preset being used
|
||||||
PaintPreset _currentPreset = PaintPreset.blackPen;
|
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<DrawingLine> _currentErasedLines = [];
|
||||||
|
|
||||||
/// Maximum number of history steps to keep
|
/// Maximum number of history steps to keep
|
||||||
final int maxHistorySteps;
|
final int maxHistorySteps;
|
||||||
|
|
||||||
@@ -40,11 +66,14 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// Current paint preset
|
/// Current paint preset
|
||||||
PaintPreset get currentPreset => _currentPreset;
|
PaintPreset get currentPreset => _currentPreset;
|
||||||
|
|
||||||
|
/// Whether eraser mode is active
|
||||||
|
bool get isEraserMode => _isEraserMode;
|
||||||
|
|
||||||
/// Whether undo is available
|
/// Whether undo is available
|
||||||
bool get canUndo => _lines.isNotEmpty;
|
bool get canUndo => _undoStack.isNotEmpty;
|
||||||
|
|
||||||
/// Whether redo is available
|
/// Whether redo is available
|
||||||
bool get canRedo => _undoneLines.isNotEmpty;
|
bool get canRedo => _redoStack.isNotEmpty;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Drawing Operations
|
// Drawing Operations
|
||||||
@@ -52,6 +81,12 @@ class DrawingController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Starts a new line at the given normalized position.
|
/// Starts a new line at the given normalized position.
|
||||||
void startLine(Offset normalizedPoint) {
|
void startLine(Offset normalizedPoint) {
|
||||||
|
if (_isEraserMode) {
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
_eraseAtPoint(normalizedPoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_currentLine = DrawingLine(
|
_currentLine = DrawingLine(
|
||||||
points: [normalizedPoint],
|
points: [normalizedPoint],
|
||||||
color: _currentPreset.color,
|
color: _currentPreset.color,
|
||||||
@@ -62,23 +97,41 @@ class DrawingController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Adds a point to the current line.
|
/// Adds a point to the current line.
|
||||||
void addPoint(Offset normalizedPoint) {
|
void addPoint(Offset normalizedPoint) {
|
||||||
|
if (_isEraserMode) {
|
||||||
|
_eraseAtPoint(normalizedPoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_currentLine == null) return;
|
if (_currentLine == null) return;
|
||||||
|
|
||||||
if (!currentLine!.isPointTooClose(normalizedPoint)) {
|
// Filter points that are too close to reduce memory usage
|
||||||
_currentLine = _currentLine!.addPoint(normalizedPoint);
|
if (_currentLine!.isPointTooClose(normalizedPoint)) return;
|
||||||
notifyListeners();
|
|
||||||
}
|
_currentLine = _currentLine!.addPoint(normalizedPoint);
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Completes the current line and adds it to the history.
|
/// Completes the current line and adds it to the history.
|
||||||
void endLine() {
|
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;
|
if (_currentLine == null) return;
|
||||||
|
|
||||||
// Only add lines with at least 2 points
|
// Only add lines with at least 2 points
|
||||||
if (_currentLine!.points.length >= 2) {
|
if (_currentLine!.points.length >= 2) {
|
||||||
_lines.add(_currentLine!);
|
_lines.add(_currentLine!);
|
||||||
// Clear redo stack when new action is performed
|
_undoStack.add(AddLineAction(_currentLine!));
|
||||||
_undoneLines.clear();
|
_redoStack.clear();
|
||||||
_trimHistory();
|
_trimHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +141,98 @@ class DrawingController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// Trims history to maxHistorySteps to prevent memory growth.
|
/// Trims history to maxHistorySteps to prevent memory growth.
|
||||||
void _trimHistory() {
|
void _trimHistory() {
|
||||||
while (_lines.length > maxHistorySteps) {
|
while (_undoStack.length > maxHistorySteps) {
|
||||||
_lines.removeAt(0);
|
_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 = <DrawingLine>[];
|
||||||
|
|
||||||
|
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
|
// Undo/Redo
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Undoes the last line drawn.
|
/// Undoes the last action.
|
||||||
void undo() {
|
void undo() {
|
||||||
if (!canUndo) return;
|
if (!canUndo) return;
|
||||||
|
|
||||||
final line = _lines.removeLast();
|
final action = _undoStack.removeLast();
|
||||||
_undoneLines.add(line);
|
|
||||||
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redoes the last undone line.
|
/// Redoes the last undone action.
|
||||||
void redo() {
|
void redo() {
|
||||||
if (!canRedo) return;
|
if (!canRedo) return;
|
||||||
|
|
||||||
final line = _undoneLines.removeLast();
|
final action = _redoStack.removeLast();
|
||||||
_lines.add(line);
|
|
||||||
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clears all lines from the canvas.
|
/// Clears all lines from the canvas.
|
||||||
void clear() {
|
void clear() {
|
||||||
_lines.clear();
|
_lines.clear();
|
||||||
_undoneLines.clear();
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
_currentLine = null;
|
_currentLine = null;
|
||||||
|
_currentErasedLines.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +296,10 @@ class DrawingController extends ChangeNotifier {
|
|||||||
// Paint Preset
|
// Paint Preset
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Sets the current paint preset.
|
/// Sets the current paint preset and disables eraser mode.
|
||||||
void setPreset(PaintPreset preset) {
|
void setPreset(PaintPreset preset) {
|
||||||
_currentPreset = preset;
|
_currentPreset = preset;
|
||||||
|
_isEraserMode = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +320,10 @@ class DrawingController extends ChangeNotifier {
|
|||||||
/// Imports lines from a JSON-serializable list.
|
/// Imports lines from a JSON-serializable list.
|
||||||
void fromJsonList(List<Map<String, dynamic>> jsonList) {
|
void fromJsonList(List<Map<String, dynamic>> jsonList) {
|
||||||
_lines.clear();
|
_lines.clear();
|
||||||
_undoneLines.clear();
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
_currentLine = null;
|
_currentLine = null;
|
||||||
|
_currentErasedLines.clear();
|
||||||
|
|
||||||
for (final json in jsonList) {
|
for (final json in jsonList) {
|
||||||
_lines.add(DrawingLine.fromJson(json));
|
_lines.add(DrawingLine.fromJson(json));
|
||||||
@@ -185,7 +357,8 @@ class DrawingController extends ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_lines.clear();
|
_lines.clear();
|
||||||
_undoneLines.clear();
|
_undoStack.clear();
|
||||||
|
_redoStack.clear();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,13 +43,24 @@ class DrawingToolbar extends StatelessWidget {
|
|||||||
...PaintPreset.quickAccess.map((preset) => _buildPresetButton(
|
...PaintPreset.quickAccess.map((preset) => _buildPresetButton(
|
||||||
context,
|
context,
|
||||||
preset,
|
preset,
|
||||||
isSelected: controller.currentPreset == preset,
|
isSelected: !controller.isEraserMode &&
|
||||||
|
controller.currentPreset == preset,
|
||||||
)),
|
)),
|
||||||
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_buildDivider(context),
|
_buildDivider(context),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
|
// Eraser button
|
||||||
|
_buildEraserButton(
|
||||||
|
context,
|
||||||
|
isSelected: controller.isEraserMode,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildDivider(context),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Undo button
|
// Undo button
|
||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
context,
|
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(
|
Widget _buildActionButton(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
|
|||||||
Reference in New Issue
Block a user