Compare commits

..

6 Commits

8 changed files with 296 additions and 118 deletions

View File

@@ -96,19 +96,33 @@ class _DrawingBoardState extends State<DrawingBoard> {
}
// Drawing mode: wrap with InteractiveViewer for zoom/pan
return Align(
alignment: widget.alignment,
child: InteractiveViewer(
// Use LayoutBuilder to get available size and center content manually
return LayoutBuilder(
builder: (context, constraints) {
// Calculate padding to center the content
final horizontalPadding =
(constraints.maxWidth - widget.boardSize.width) / 2;
final verticalPadding =
(constraints.maxHeight - widget.boardSize.height) / 2;
return InteractiveViewer(
transformationController: _transformationController,
minScale: widget.minScale,
maxScale: widget.maxScale,
boundaryMargin: EdgeInsets.zero,
constrained: true,
boundaryMargin: const EdgeInsets.all(double.infinity),
constrained: false,
panEnabled: !_isDrawing,
scaleEnabled: !_isDrawing,
child: Padding(
padding: EdgeInsets.only(
left: horizontalPadding > 0 ? horizontalPadding : 0,
top: verticalPadding > 0 ? verticalPadding : 0,
),
child: content,
),
);
},
);
}
/// Builds the drawing layer with gesture handling.

View File

@@ -55,10 +55,7 @@ class DrawingPainter extends CustomPainter {
for (int i = 1; i < line.points.length - 1; i++) {
final p0 = _toCanvasPoint(line.points[i]);
final p1 = _toCanvasPoint(line.points[i + 1]);
final midPoint = Offset(
(p0.dx + p1.dx) / 2,
(p0.dy + p1.dy) / 2,
);
final midPoint = Offset((p0.dx + p1.dx) / 2, (p0.dy + p1.dy) / 2);
path.quadraticBezierTo(p0.dx, p0.dy, midPoint.dx, midPoint.dy);
}
// Draw to the last point
@@ -116,61 +113,3 @@ class DrawingOverlay extends StatelessWidget {
);
}
}
/// A widget that handles drawing input and renders lines.
///
/// Converts touch/pointer events to normalized coordinates and
/// passes them to the [DrawingController].
class DrawingCanvas extends StatelessWidget {
final DrawingController controller;
final Size canvasSize;
final bool enabled;
const DrawingCanvas({
super.key,
required this.controller,
required this.canvasSize,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return Listener(
onPointerDown: enabled ? _onPointerDown : null,
onPointerMove: enabled ? _onPointerMove : null,
onPointerUp: enabled ? _onPointerUp : null,
onPointerCancel: enabled ? _onPointerCancel : null,
behavior: HitTestBehavior.opaque,
child: DrawingOverlay(
controller: controller,
canvasSize: canvasSize,
),
);
}
void _onPointerDown(PointerDownEvent event) {
final normalized = _toNormalized(event.localPosition);
controller.startLine(normalized);
}
void _onPointerMove(PointerMoveEvent event) {
final normalized = _toNormalized(event.localPosition);
controller.addPoint(normalized);
}
void _onPointerUp(PointerUpEvent event) {
controller.endLine();
}
void _onPointerCancel(PointerCancelEvent event) {
controller.endLine();
}
/// Converts canvas coordinates to normalized coordinates (0-1).
Offset _toNormalized(Offset canvasPoint) {
return Offset(
(canvasPoint.dx / canvasSize.width).clamp(0.0, 1.0),
(canvasPoint.dy / canvasSize.height).clamp(0.0, 1.0),
);
}
}

View File

@@ -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<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
@@ -13,8 +30,11 @@ class DrawingController extends ChangeNotifier {
/// All completed lines in the drawing
final List<DrawingLine> _lines = [];
/// Lines that have been undone (for redo functionality)
final List<DrawingLine> _undoneLines = [];
/// 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;
@@ -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<DrawingLine> _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,21 +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;
// 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();
}
@@ -86,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 = <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();
}
}
@@ -95,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();
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();
}
@@ -125,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();
}
@@ -148,8 +320,10 @@ class DrawingController extends ChangeNotifier {
/// Imports lines from a JSON-serializable list.
void fromJsonList(List<Map<String, dynamic>> jsonList) {
_lines.clear();
_undoneLines.clear();
_undoStack.clear();
_redoStack.clear();
_currentLine = null;
_currentErasedLines.clear();
for (final json in jsonList) {
_lines.add(DrawingLine.fromJson(json));
@@ -183,7 +357,8 @@ class DrawingController extends ChangeNotifier {
@override
void dispose() {
_lines.clear();
_undoneLines.clear();
_undoStack.clear();
_redoStack.clear();
super.dispose();
}
}

View File

@@ -8,6 +8,9 @@ import 'dart:ui';
///
/// This allows drawings to scale correctly when the canvas size changes.
class DrawingLine {
/// The minimal squared distance between to points which are normalized so that this point is allowed to be added to the line
static const minNormalizedPointDistanceSquared = 0.001 * 0.001;
/// Points in normalized coordinates (0.0 to 1.0)
final List<Offset> points;
@@ -27,10 +30,9 @@ class DrawingLine {
/// Creates a DrawingLine from JSON data.
factory DrawingLine.fromJson(Map<String, dynamic> json) {
final pointsList = (json['points'] as List)
.map((p) => Offset(
(p['x'] as num).toDouble(),
(p['y'] as num).toDouble(),
))
.map(
(p) => Offset((p['x'] as num).toDouble(), (p['y'] as num).toDouble()),
)
.toList();
return DrawingLine(
@@ -58,6 +60,14 @@ class DrawingLine {
);
}
bool isPointTooClose(Offset nextNormalizedPoint) {
if (points.isEmpty) {
return false;
}
return (points.last - nextNormalizedPoint).distanceSquared <
minNormalizedPointDistanceSquared;
}
/// Creates a copy with updated points.
DrawingLine copyWith({
List<Offset>? points,
@@ -85,9 +95,5 @@ class DrawingLine {
}
@override
int get hashCode => Object.hash(
Object.hashAll(points),
color,
strokeWidth,
);
int get hashCode => Object.hash(Object.hashAll(points), color, strokeWidth);
}

View File

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

View File

@@ -57,7 +57,7 @@ class PaintPreset {
static const yellowMarker = PaintPreset(
name: 'Yellow Marker',
color: Color(0x80FFEB3B), // Yellow with 50% opacity
strokeWidth: 0.015, // Thicker for highlighting
strokeWidth: 0.02, // Thicker for highlighting
icon: Icons.highlight,
);
@@ -65,7 +65,7 @@ class PaintPreset {
static const greenMarker = PaintPreset(
name: 'Green Marker',
color: Color(0x804CAF50), // Green with 50% opacity
strokeWidth: 0.015,
strokeWidth: 0.018,
icon: Icons.highlight,
);
@@ -91,7 +91,10 @@ class PaintPreset {
static const List<PaintPreset> quickAccess = [
blackPen,
redPen,
bluePen,
yellowMarker,
greenMarker,
pinkMarker,
];
@override

View File

@@ -271,8 +271,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
icon: Icon(
widget.config.fullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
),
tooltip:
widget.config.fullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen',
tooltip: widget.config.fullscreen
? 'Exit Fullscreen'
: 'Enter Fullscreen',
onPressed: _toggleFullscreen,
),
IconButton(
@@ -284,8 +285,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
icon: Icon(
widget.config.twoPageMode ? Icons.filter_1 : Icons.filter_2,
),
tooltip:
widget.config.twoPageMode ? 'Single Page Mode' : 'Two Page Mode',
tooltip: widget.config.twoPageMode
? 'Single Page Mode'
: 'Two Page Mode',
onPressed: _toggleTwoPageMode,
),
],
@@ -317,8 +319,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
currentPageNumber: _currentPage,
config: widget.config,
leftDrawingController: _leftDrawingController,
rightDrawingController:
widget.config.twoPageMode ? _rightDrawingController : null,
rightDrawingController: widget.config.twoPageMode
? _rightDrawingController
: null,
drawingEnabled: _isPaintMode,
);
@@ -334,7 +337,6 @@ class _SheetViewerPageState extends State<SheetViewerPage>
onToggleFullscreen: _toggleFullscreen,
onExit: () => Navigator.pop(context),
onPageTurn: _turnPage,
child: pageDisplay,
);
}
@@ -356,10 +358,9 @@ class _SheetViewerPageState extends State<SheetViewerPage>
padding: const EdgeInsets.all(16.0),
child: Text(
message,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Colors.red),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: Colors.red),
textAlign: TextAlign.center,
),
),

View File

@@ -14,7 +14,6 @@ typedef PageTurnCallback = dynamic Function(int delta);
/// - Right side: Turn page forward (+1 or +2 in two-page mode)
class TouchNavigationLayer extends StatelessWidget {
final PdfPageDisplay pageDisplay;
final Widget child;
final Config config;
final VoidCallback onToggleFullscreen;
final VoidCallback onExit;
@@ -23,7 +22,6 @@ class TouchNavigationLayer extends StatelessWidget {
const TouchNavigationLayer({
super.key,
required this.pageDisplay,
required this.child,
required this.config,
required this.onToggleFullscreen,
required this.onExit,
@@ -35,7 +33,7 @@ class TouchNavigationLayer extends StatelessWidget {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) => _handleTap(context, details),
child: child,
child: pageDisplay,
);
}