Bug fixes (Flutter): - Wrap multi-statement DB writes (insert/update/delete note, deleteDocument, deletePageData, OCR FTS merge, migrations) in transactions to prevent data loss on interruption and a read-modify-write FTS race. - Fix PdfDocument leaks on exception (try/finally dispose) and preserve image aspect ratio when stamping images onto PDF pages. - Guard file-picker against empty selection (was .single -> crash). - Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF pages; capture page synchronously on save to stop wrong-page data loss. - Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race, and search N+1; transform stored annotations on PDF page rotation. - Normalize pen pressure for devices without a pressure range. - PPT: single source of truth for slide strokes so ink displays and exports. UI/UX: - Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/ save/find), toolbar overflow handling, friendlier empty states, semantic OCR status badges, relative timestamps, 1-based page indicators, large-deck PPT navigation, and a scratchpad-scope label in split view. Server (optional backend): - Persist JWT secret (was per-process random), block path traversal in storage, fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync guard, constant-time login, and split out heavy OCR deps so the API/tests run without them. CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a Windows release build; pristine `flutter analyze`, all Flutter and server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
710 lines
19 KiB
Dart
710 lines
19 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import '../models/ink_point.dart';
|
|
import '../models/ink_stroke.dart';
|
|
import '../models/pen_tool.dart';
|
|
import '../models/pointer_device_kind.dart';
|
|
import '../models/pressure_curve.dart';
|
|
import '../utils/stroke_stabilizer.dart';
|
|
|
|
/// Controls whether the canvas accepts drawing input or passes events through.
|
|
enum InteractionMode { draw, navigate }
|
|
|
|
class InkCanvas extends StatefulWidget {
|
|
final List<InkStroke> strokes;
|
|
final void Function(InkStroke stroke)? onStrokeComplete;
|
|
final void Function(String strokeId, List<InkStroke> replacements)? onErase;
|
|
final PenTool tool;
|
|
final Color color;
|
|
final double strokeWidth;
|
|
final PressureCurve pressureCurve;
|
|
final StabilizationLevel stabilizationLevel;
|
|
final bool filled;
|
|
final InteractionMode interactionMode;
|
|
final Rect? viewportBounds;
|
|
|
|
const InkCanvas({
|
|
super.key,
|
|
required this.strokes,
|
|
this.onStrokeComplete,
|
|
this.onErase,
|
|
this.tool = PenTool.pen,
|
|
this.color = Colors.black,
|
|
this.strokeWidth = 2.0,
|
|
this.pressureCurve = PressureCurve.linear,
|
|
this.stabilizationLevel = StabilizationLevel.none,
|
|
this.filled = false,
|
|
this.interactionMode = InteractionMode.draw,
|
|
this.viewportBounds,
|
|
});
|
|
|
|
@override
|
|
State<InkCanvas> createState() => _InkCanvasState();
|
|
}
|
|
|
|
class _InkCanvasState extends State<InkCanvas> {
|
|
final List<InkPoint> _currentPoints = [];
|
|
bool _isDrawing = false;
|
|
PenTool? _activeTool;
|
|
StrokeStabilizer? _stabilizer;
|
|
|
|
/// Start point for shape tools.
|
|
InkPoint? _shapeStart;
|
|
|
|
/// Whether the active tool is a shape tool (needs only 2 points).
|
|
bool get _isShapeTool {
|
|
final t = _activeTool ?? widget.tool;
|
|
return t == PenTool.rectangle ||
|
|
t == PenTool.ellipse ||
|
|
t == PenTool.line ||
|
|
t == PenTool.arrow;
|
|
}
|
|
|
|
/// Whether the active tool is the text tool.
|
|
bool get _isTextTool {
|
|
return (_activeTool ?? widget.tool) == PenTool.text;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(InkCanvas oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.stabilizationLevel != widget.stabilizationLevel) {
|
|
_stabilizer = StrokeStabilizer(level: widget.stabilizationLevel);
|
|
}
|
|
}
|
|
|
|
InputDeviceKind _mapKind(PointerDeviceKind kind) {
|
|
switch (kind) {
|
|
case PointerDeviceKind.touch:
|
|
return InputDeviceKind.touch;
|
|
case PointerDeviceKind.mouse:
|
|
return InputDeviceKind.mouse;
|
|
case PointerDeviceKind.stylus:
|
|
return InputDeviceKind.stylus;
|
|
case PointerDeviceKind.invertedStylus:
|
|
return InputDeviceKind.invertedStylus;
|
|
case PointerDeviceKind.trackpad:
|
|
return InputDeviceKind.trackpad;
|
|
case PointerDeviceKind.unknown:
|
|
return InputDeviceKind.unknown;
|
|
}
|
|
}
|
|
|
|
InkPoint _makePoint(PointerEvent event) {
|
|
return InkPoint(
|
|
x: event.localPosition.dx,
|
|
y: event.localPosition.dy,
|
|
pressure: event.pressure,
|
|
tilt: event is PointerMoveEvent ? event.tilt : 0.0,
|
|
timestamp: event.timeStamp.inMicroseconds,
|
|
pointerDeviceKind: _mapKind(event.kind),
|
|
);
|
|
}
|
|
|
|
void _handlePointerDown(PointerDownEvent event) {
|
|
if (event.kind == PointerDeviceKind.trackpad) return;
|
|
|
|
// In navigate mode, no drawing at all — pass all events through.
|
|
if (widget.interactionMode == InteractionMode.navigate) return;
|
|
|
|
// In draw mode: stylus and mouse draw, touch passes through for scrolling.
|
|
if (event.kind == PointerDeviceKind.touch) return;
|
|
|
|
_isDrawing = true;
|
|
_activeTool = widget.tool;
|
|
|
|
if (event.kind == PointerDeviceKind.invertedStylus) {
|
|
_activeTool = PenTool.eraser;
|
|
}
|
|
|
|
final point = _makePoint(event);
|
|
|
|
if (_activeTool == PenTool.eraser) {
|
|
_eraseAt(point);
|
|
} else if (_isTextTool) {
|
|
// Text tool: record position, handled on pointer up
|
|
_shapeStart = point;
|
|
} else if (_isShapeTool) {
|
|
// Shape tool: record start point
|
|
_shapeStart = point;
|
|
_stabilizer?.reset();
|
|
setState(() {
|
|
_currentPoints.clear();
|
|
_currentPoints.add(point);
|
|
});
|
|
} else {
|
|
// Freehand tools (pen, marker, highlighter)
|
|
_stabilizer?.reset();
|
|
final smoothed = _stabilizer?.filter(point) ?? point;
|
|
setState(() {
|
|
_currentPoints.clear();
|
|
_currentPoints.add(smoothed);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _handlePointerMove(PointerMoveEvent event) {
|
|
if (!_isDrawing) return;
|
|
|
|
final point = _makePoint(event);
|
|
|
|
if (_activeTool == PenTool.eraser) {
|
|
_eraseAt(point);
|
|
} else if (_isTextTool) {
|
|
// No preview for text tool
|
|
return;
|
|
} else if (_isShapeTool) {
|
|
// Shape preview: keep only start + current
|
|
setState(() {
|
|
if (_currentPoints.length >= 2) {
|
|
_currentPoints[1] = point;
|
|
} else {
|
|
_currentPoints.add(point);
|
|
}
|
|
});
|
|
} else {
|
|
// Freehand
|
|
final smoothed = _stabilizer?.filter(point) ?? point;
|
|
setState(() {
|
|
_currentPoints.add(smoothed);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _handlePointerUp(PointerUpEvent event) {
|
|
if (!_isDrawing) return;
|
|
_isDrawing = false;
|
|
|
|
final activeTool = _activeTool ?? widget.tool;
|
|
|
|
if (activeTool == PenTool.eraser) {
|
|
// Nothing to finalize
|
|
} else if (_isTextTool) {
|
|
if (_shapeStart != null) {
|
|
_showTextDialog(_shapeStart!);
|
|
}
|
|
} else if (_isShapeTool) {
|
|
// Shape: finalize with start + end points
|
|
if (_currentPoints.length >= 2) {
|
|
final stroke = InkStroke(
|
|
id: _generateId(),
|
|
points: List.from(_currentPoints),
|
|
tool: activeTool,
|
|
color: _getColorForTool(activeTool).toARGB32(),
|
|
strokeWidth: widget.strokeWidth,
|
|
createdAt: DateTime.now(),
|
|
filled: widget.filled,
|
|
);
|
|
widget.onStrokeComplete?.call(stroke);
|
|
}
|
|
} else if (_currentPoints.isNotEmpty) {
|
|
// Freehand
|
|
final stroke = InkStroke(
|
|
id: _generateId(),
|
|
points: List.from(_currentPoints),
|
|
tool: activeTool,
|
|
color: _getColorForTool(activeTool).toARGB32(),
|
|
strokeWidth: activeTool == PenTool.highlighter
|
|
? widget.strokeWidth * 3
|
|
: widget.strokeWidth,
|
|
createdAt: DateTime.now(),
|
|
);
|
|
widget.onStrokeComplete?.call(stroke);
|
|
}
|
|
|
|
setState(() {
|
|
_currentPoints.clear();
|
|
_shapeStart = null;
|
|
});
|
|
}
|
|
|
|
void _showTextDialog(InkPoint position) {
|
|
final controller = TextEditingController();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: const Text('Add Text'),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(hintText: 'Enter text...'),
|
|
maxLines: null,
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
final text = controller.text.trim();
|
|
if (text.isNotEmpty) {
|
|
final stroke = InkStroke(
|
|
id: _generateId(),
|
|
points: [position],
|
|
tool: PenTool.text,
|
|
color: widget.color.toARGB32(),
|
|
strokeWidth: widget.strokeWidth,
|
|
createdAt: DateTime.now(),
|
|
textContent: text,
|
|
fontSize: widget.strokeWidth * 7,
|
|
);
|
|
widget.onStrokeComplete?.call(stroke);
|
|
}
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: const Text('OK'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _eraseAt(InkPoint point) {
|
|
final eraserRadius = widget.strokeWidth * 3;
|
|
|
|
// Collect all (strokeId, replacements) pairs before invoking any callback,
|
|
// to avoid ConcurrentModificationError when the parent's onErase triggers
|
|
// a setState that mutates widget.strokes mid-iteration.
|
|
final toErase = <(String, List<InkStroke>)>[];
|
|
|
|
for (final stroke in widget.strokes) {
|
|
if (stroke.tool == PenTool.eraser) continue;
|
|
|
|
final erasedIndices = <int>{};
|
|
for (int i = 0; i < stroke.points.length; i++) {
|
|
final p = stroke.points[i];
|
|
final dx = p.x - point.x;
|
|
final dy = p.y - point.y;
|
|
if (dx * dx + dy * dy < eraserRadius * eraserRadius) {
|
|
erasedIndices.add(i);
|
|
}
|
|
}
|
|
|
|
if (erasedIndices.isEmpty) continue;
|
|
|
|
toErase.add((stroke.id, _splitStroke(stroke, erasedIndices)));
|
|
}
|
|
|
|
for (final (strokeId, replacements) in toErase) {
|
|
widget.onErase?.call(strokeId, replacements);
|
|
}
|
|
}
|
|
|
|
List<InkStroke> _splitStroke(InkStroke stroke, Set<int> erasedIndices) {
|
|
final segments = <List<InkPoint>>[];
|
|
List<InkPoint> currentSegment = [];
|
|
|
|
for (int i = 0; i < stroke.points.length; i++) {
|
|
if (erasedIndices.contains(i)) {
|
|
if (currentSegment.isNotEmpty) {
|
|
segments.add(currentSegment);
|
|
currentSegment = [];
|
|
}
|
|
} else {
|
|
currentSegment.add(stroke.points[i]);
|
|
}
|
|
}
|
|
|
|
if (currentSegment.isNotEmpty) {
|
|
segments.add(currentSegment);
|
|
}
|
|
|
|
final replacements = <InkStroke>[];
|
|
for (final segment in segments) {
|
|
if (segment.length >= 2) {
|
|
replacements.add(
|
|
InkStroke(
|
|
id: _generateId(),
|
|
points: segment,
|
|
tool: stroke.tool,
|
|
color: stroke.color,
|
|
strokeWidth: stroke.strokeWidth,
|
|
createdAt: stroke.createdAt,
|
|
filled: stroke.filled,
|
|
textContent: stroke.textContent,
|
|
fontSize: stroke.fontSize,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return replacements;
|
|
}
|
|
|
|
Color _getColorForTool(PenTool tool) {
|
|
switch (tool) {
|
|
case PenTool.marker:
|
|
return widget.color.withAlpha(77);
|
|
case PenTool.highlighter:
|
|
return const Color(0x80FFFF00);
|
|
case PenTool.pen:
|
|
case PenTool.eraser:
|
|
case PenTool.rectangle:
|
|
case PenTool.ellipse:
|
|
case PenTool.line:
|
|
case PenTool.arrow:
|
|
case PenTool.text:
|
|
return widget.color;
|
|
}
|
|
}
|
|
|
|
String _generateId() {
|
|
return const Uuid().v4();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Listener(
|
|
onPointerDown: _handlePointerDown,
|
|
onPointerMove: _handlePointerMove,
|
|
onPointerUp: _handlePointerUp,
|
|
child: CustomPaint(
|
|
painter: _InkPainter(
|
|
strokes: widget.strokes,
|
|
currentPoints: _currentPoints,
|
|
currentTool: _activeTool ?? widget.tool,
|
|
currentColor: _getColorForTool(_activeTool ?? widget.tool),
|
|
currentStrokeWidth:
|
|
(_activeTool ?? widget.tool) == PenTool.highlighter
|
|
? widget.strokeWidth * 3
|
|
: widget.strokeWidth,
|
|
pressureCurve: widget.pressureCurve,
|
|
filled: widget.filled,
|
|
viewportBounds: widget.viewportBounds,
|
|
),
|
|
size: Size.infinite,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InkPainter extends CustomPainter {
|
|
final List<InkStroke> strokes;
|
|
final List<InkPoint> currentPoints;
|
|
final PenTool currentTool;
|
|
final Color currentColor;
|
|
final double currentStrokeWidth;
|
|
final PressureCurve pressureCurve;
|
|
final bool filled;
|
|
final Rect? viewportBounds;
|
|
|
|
_InkPainter({
|
|
required this.strokes,
|
|
required this.currentPoints,
|
|
required this.currentTool,
|
|
required this.currentColor,
|
|
required this.currentStrokeWidth,
|
|
required this.pressureCurve,
|
|
required this.filled,
|
|
this.viewportBounds,
|
|
});
|
|
|
|
bool _strokeInViewport(InkStroke stroke, Rect viewport) {
|
|
if (stroke.points.isEmpty) return false;
|
|
double minX = double.infinity, minY = double.infinity;
|
|
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
|
for (final p in stroke.points) {
|
|
if (p.x < minX) minX = p.x;
|
|
if (p.y < minY) minY = p.y;
|
|
if (p.x > maxX) maxX = p.x;
|
|
if (p.y > maxY) maxY = p.y;
|
|
}
|
|
return viewport.overlaps(Rect.fromLTRB(minX, minY, maxX, maxY));
|
|
}
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
for (final stroke in strokes) {
|
|
if (stroke.tool == PenTool.eraser) continue;
|
|
if (viewportBounds != null &&
|
|
!_strokeInViewport(stroke, viewportBounds!)) {
|
|
continue;
|
|
}
|
|
_drawStroke(
|
|
canvas,
|
|
stroke.points,
|
|
stroke.tool,
|
|
Color(stroke.color),
|
|
stroke.strokeWidth,
|
|
true,
|
|
stroke.filled,
|
|
stroke.textContent,
|
|
stroke.fontSize,
|
|
);
|
|
}
|
|
|
|
if (currentPoints.isNotEmpty && currentTool != PenTool.eraser) {
|
|
_drawStroke(
|
|
canvas,
|
|
currentPoints,
|
|
currentTool,
|
|
currentColor,
|
|
currentStrokeWidth,
|
|
false,
|
|
filled,
|
|
null,
|
|
14.0,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _drawStroke(
|
|
Canvas canvas,
|
|
List<InkPoint> points,
|
|
PenTool tool,
|
|
Color color,
|
|
double strokeWidth,
|
|
bool isComplete,
|
|
bool strokeFilled,
|
|
String? textContent,
|
|
double fontSize,
|
|
) {
|
|
if (points.isEmpty) return;
|
|
|
|
switch (tool) {
|
|
case PenTool.pen:
|
|
case PenTool.marker:
|
|
case PenTool.highlighter:
|
|
case PenTool.eraser:
|
|
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
|
break;
|
|
case PenTool.rectangle:
|
|
if (points.length < 2) {
|
|
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
|
} else {
|
|
_drawRect(canvas, points, color, strokeWidth, strokeFilled);
|
|
}
|
|
break;
|
|
case PenTool.ellipse:
|
|
if (points.length < 2) {
|
|
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
|
} else {
|
|
_drawOval(canvas, points, color, strokeWidth, strokeFilled);
|
|
}
|
|
break;
|
|
case PenTool.line:
|
|
if (points.length < 2) {
|
|
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
|
} else {
|
|
_drawLine(canvas, points, color, strokeWidth);
|
|
}
|
|
break;
|
|
case PenTool.arrow:
|
|
if (points.length < 2) {
|
|
_drawFreehand(canvas, points, tool, color, strokeWidth, isComplete);
|
|
} else {
|
|
_drawArrow(canvas, points, color, strokeWidth);
|
|
}
|
|
break;
|
|
case PenTool.text:
|
|
if (textContent != null && textContent.isNotEmpty) {
|
|
_drawText(canvas, points, textContent, fontSize, color);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _drawFreehand(
|
|
Canvas canvas,
|
|
List<InkPoint> points,
|
|
PenTool tool,
|
|
Color color,
|
|
double strokeWidth,
|
|
bool isComplete,
|
|
) {
|
|
final pfPoints = points
|
|
.map(
|
|
(p) => pf.Point(
|
|
p.x,
|
|
p.y,
|
|
pressureCurve.apply(p.pressure).clamp(0.0, 1.0),
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
final thinning = (tool == PenTool.marker || tool == PenTool.highlighter)
|
|
? 0.0
|
|
: 0.7;
|
|
|
|
final outlinePoints = pf.getStroke(
|
|
pfPoints,
|
|
size: strokeWidth,
|
|
thinning: thinning,
|
|
smoothing: 0.5,
|
|
streamline: 0.5,
|
|
taperStart: 0.0,
|
|
taperEnd: 0.0,
|
|
capStart: true,
|
|
capEnd: true,
|
|
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
|
|
isComplete: isComplete,
|
|
);
|
|
|
|
if (outlinePoints.isEmpty) return;
|
|
|
|
final path = Path();
|
|
path.moveTo(outlinePoints[0].x, outlinePoints[0].y);
|
|
|
|
for (int i = 1; i < outlinePoints.length; i++) {
|
|
path.lineTo(outlinePoints[i].x, outlinePoints[i].y);
|
|
}
|
|
path.close();
|
|
|
|
final paint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.fill
|
|
..isAntiAlias = true;
|
|
|
|
canvas.drawPath(path, paint);
|
|
}
|
|
|
|
void _drawRect(
|
|
Canvas canvas,
|
|
List<InkPoint> points,
|
|
Color color,
|
|
double strokeWidth,
|
|
bool strokeFilled,
|
|
) {
|
|
final rect = Rect.fromPoints(
|
|
Offset(points[0].x, points[0].y),
|
|
Offset(points[1].x, points[1].y),
|
|
);
|
|
|
|
final paint = Paint()
|
|
..color = color
|
|
..strokeWidth = strokeWidth
|
|
..isAntiAlias = true
|
|
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
|
|
|
canvas.drawRect(rect, paint);
|
|
}
|
|
|
|
void _drawOval(
|
|
Canvas canvas,
|
|
List<InkPoint> points,
|
|
Color color,
|
|
double strokeWidth,
|
|
bool strokeFilled,
|
|
) {
|
|
final rect = Rect.fromPoints(
|
|
Offset(points[0].x, points[0].y),
|
|
Offset(points[1].x, points[1].y),
|
|
);
|
|
|
|
final paint = Paint()
|
|
..color = color
|
|
..strokeWidth = strokeWidth
|
|
..isAntiAlias = true
|
|
..style = strokeFilled ? PaintingStyle.fill : PaintingStyle.stroke;
|
|
|
|
canvas.drawOval(rect, paint);
|
|
}
|
|
|
|
void _drawLine(
|
|
Canvas canvas,
|
|
List<InkPoint> points,
|
|
Color color,
|
|
double strokeWidth,
|
|
) {
|
|
final paint = Paint()
|
|
..color = color
|
|
..strokeWidth = strokeWidth
|
|
..isAntiAlias = true
|
|
..style = PaintingStyle.stroke
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
canvas.drawLine(
|
|
Offset(points[0].x, points[0].y),
|
|
Offset(points[1].x, points[1].y),
|
|
paint,
|
|
);
|
|
}
|
|
|
|
void _drawArrow(
|
|
Canvas canvas,
|
|
List<InkPoint> points,
|
|
Color color,
|
|
double strokeWidth,
|
|
) {
|
|
final p1 = Offset(points[0].x, points[0].y);
|
|
final p2 = Offset(points[1].x, points[1].y);
|
|
|
|
final paint = Paint()
|
|
..color = color
|
|
..strokeWidth = strokeWidth
|
|
..isAntiAlias = true
|
|
..style = PaintingStyle.stroke
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
// Main line
|
|
canvas.drawLine(p1, p2, paint);
|
|
|
|
// Arrowhead
|
|
final dx = p2.dx - p1.dx;
|
|
final dy = p2.dy - p1.dy;
|
|
final angle = atan2(dy, dx);
|
|
final arrowLength = strokeWidth * 5;
|
|
const arrowAngle = pi / 6; // 30 degrees
|
|
|
|
final arrowP1 = Offset(
|
|
p2.dx - arrowLength * cos(angle - arrowAngle),
|
|
p2.dy - arrowLength * sin(angle - arrowAngle),
|
|
);
|
|
final arrowP2 = Offset(
|
|
p2.dx - arrowLength * cos(angle + arrowAngle),
|
|
p2.dy - arrowLength * sin(angle + arrowAngle),
|
|
);
|
|
|
|
canvas.drawLine(p2, arrowP1, paint);
|
|
canvas.drawLine(p2, arrowP2, paint);
|
|
}
|
|
|
|
void _drawText(
|
|
Canvas canvas,
|
|
List<InkPoint> points,
|
|
String text,
|
|
double fontSize,
|
|
Color color,
|
|
) {
|
|
final textPainter = TextPainter(
|
|
text: TextSpan(
|
|
text: text,
|
|
style: TextStyle(color: color, fontSize: fontSize),
|
|
),
|
|
textDirection: TextDirection.ltr,
|
|
);
|
|
textPainter.layout();
|
|
textPainter.paint(canvas, Offset(points[0].x, points[0].y));
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _InkPainter oldDelegate) {
|
|
if (strokes.length != oldDelegate.strokes.length) return true;
|
|
if (currentPoints.length != oldDelegate.currentPoints.length) return true;
|
|
for (int i = 0; i < strokes.length; i++) {
|
|
final a = strokes[i], b = oldDelegate.strokes[i];
|
|
if (a.id != b.id ||
|
|
a.color != b.color ||
|
|
a.strokeWidth != b.strokeWidth ||
|
|
a.tool != b.tool) {
|
|
return true;
|
|
}
|
|
}
|
|
return currentTool != oldDelegate.currentTool;
|
|
}
|
|
}
|