Files
sheetless/lib/features/sheet_viewer/drawing/drawing_controller.dart
2026-02-05 18:41:43 +01:00

365 lines
9.8 KiB
Dart

import 'dart:convert';
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<DrawingLine> erasedLines;
const EraseAction(this.erasedLines);
}
/// Controller for managing drawing state with undo/redo support.
///
/// Manages a stack of [DrawingLine] objects and provides methods for
/// drawing, undoing, redoing, and serializing the drawing state.
class DrawingController extends ChangeNotifier {
/// All completed lines in the drawing
final List<DrawingLine> _lines = [];
/// Stack of actions for undo functionality
final List<DrawingAction> _undoStack = [];
/// Stack of actions for redo functionality
final List<DrawingAction> _redoStack = [];
/// The line currently being drawn (null when not drawing)
DrawingLine? _currentLine;
/// 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<DrawingLine> _currentErasedLines = [];
/// Maximum number of history steps to keep
final int maxHistorySteps;
DrawingController({this.maxHistorySteps = 50});
// ---------------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------------
/// All completed lines (read-only)
List<DrawingLine> get lines => List.unmodifiable(_lines);
/// The line currently being drawn
DrawingLine? get currentLine => _currentLine;
/// Current paint preset
PaintPreset get currentPreset => _currentPreset;
/// Whether eraser mode is active
bool get isEraserMode => _isEraserMode;
/// Whether undo is available
bool get canUndo => _undoStack.isNotEmpty;
/// Whether redo is available
bool get canRedo => _redoStack.isNotEmpty;
// ---------------------------------------------------------------------------
// Drawing Operations
// ---------------------------------------------------------------------------
/// 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,
strokeWidth: _currentPreset.strokeWidth,
);
notifyListeners();
}
/// Adds a point to the current line.
void addPoint(Offset normalizedPoint) {
if (_isEraserMode) {
_eraseAtPoint(normalizedPoint);
return;
}
if (_currentLine == null) return;
// 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!);
_undoStack.add(AddLineAction(_currentLine!));
_redoStack.clear();
_trimHistory();
}
_currentLine = null;
notifyListeners();
}
/// Trims history to maxHistorySteps to prevent memory growth.
void _trimHistory() {
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 = <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();
}
}
// ---------------------------------------------------------------------------
// Undo/Redo
// ---------------------------------------------------------------------------
/// Undoes the last action.
void undo() {
if (!canUndo) return;
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 action.
void redo() {
if (!canRedo) return;
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();
_undoStack.clear();
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
notifyListeners();
}
// ---------------------------------------------------------------------------
// Paint Preset
// ---------------------------------------------------------------------------
/// Sets the current paint preset and disables eraser mode.
void setPreset(PaintPreset preset) {
_currentPreset = preset;
_isEraserMode = false;
notifyListeners();
}
// ---------------------------------------------------------------------------
// Serialization
// ---------------------------------------------------------------------------
/// Exports all lines to a JSON-serializable list.
List<Map<String, dynamic>> toJsonList() {
return _lines.map((line) => line.toJson()).toList();
}
/// Exports all lines to a JSON string.
String toJsonString() {
return jsonEncode(toJsonList());
}
/// Imports lines from a JSON-serializable list.
void fromJsonList(List<Map<String, dynamic>> jsonList) {
_lines.clear();
_undoStack.clear();
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
for (final json in jsonList) {
_lines.add(DrawingLine.fromJson(json));
}
notifyListeners();
}
/// Imports lines from a JSON string.
void fromJsonString(String jsonString) {
if (jsonString.isEmpty || jsonString == '[]') {
clear();
return;
}
final decoded = jsonDecode(jsonString) as List;
if (decoded.isEmpty) {
clear();
return;
}
fromJsonList(decoded.cast<Map<String, dynamic>>());
}
/// Adds existing lines without clearing (for merging annotations).
void addLines(List<DrawingLine> newLines) {
_lines.addAll(newLines);
_trimHistory();
notifyListeners();
}
@override
void dispose() {
_lines.clear();
_undoStack.clear();
_redoStack.clear();
super.dispose();
}
}