Files
BadNote/lib/widgets/ink_canvas.dart

710 lines
19 KiB
Dart
Raw Normal View History

Fix bugs across app + server, optimize UI/UX, add Gitea CI 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>
2026-06-21 03:18:00 +08:00
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(
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
(p) => pf.PointVector(
Fix bugs across app + server, optimize UI/UX, add Gitea CI 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>
2026-06-21 03:18:00 +08:00
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,
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
options: pf.StrokeOptions(
size: strokeWidth,
thinning: thinning,
smoothing: 0.5,
streamline: 0.5,
// 2.x defaults: no taper + capped ends (was taperStart/End:0 +
// capStart/End:true in 1.0.4).
simulatePressure: tool != PenTool.marker && tool != PenTool.highlighter,
isComplete: isComplete,
),
Fix bugs across app + server, optimize UI/UX, add Gitea CI 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>
2026-06-21 03:18:00 +08:00
);
if (outlinePoints.isEmpty) return;
final path = Path();
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
path.moveTo(outlinePoints[0].dx, outlinePoints[0].dy);
Fix bugs across app + server, optimize UI/UX, add Gitea CI 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>
2026-06-21 03:18:00 +08:00
for (int i = 1; i < outlinePoints.length; i++) {
feat(pen): pressure-responsive width, configurable thinning, native Windows pen (tilt/buttons) W1 — Custom pen width + pressure sensitivity (Saber-style): - Root cause of "压感没用": perfect_freehand 1.0.4 IGNORES real stylus pressure (hardcodes radius=size/2 when simulatePressure=false) — width never tracked pen force. Upgraded perfect_freehand ^1.0.0 -> ^2.0.0 (honors real pressure); migrated all 5 getStroke call sites to the 2.x API (PointVector / StrokeOptions / Offset). - De-hardcoded `thinning` into `kDefaultPenThinning` (0.85), single source shared by the on-screen painter and the PDF export path; exposed as PenConfig.pressureSensitivity with a Pressure Sensitivity slider; live-applies via a config listener. W3 — Native Windows pen plugin (tilt + barrel/eraser buttons): - windows/runner/pen_channel.{h,cpp}: observe WM_POINTER at the TOP of MessageHandler (before HandleTopLevelWindowProc, which Flutter uses to consume pen events), read GetPointerPenInfo penFlags + tilt, stream over EventChannel('badnote/pen'); non-consuming. - PenInputService: single latched hardware state (no Win32-pointerId<->event.pointer correlation); graceful no-op off-Windows. - pen_canvas maps barrel/inverted/eraser through PenConfig.sideButton/eraserEnd (eraser/undo/toggleTool/pan) and captures tilt into PenPoint.tilt -> EditorPoint.tilt. W2 — Zoom flicker: page raster isolated in its own RepaintBoundary (safe interim); definitive crisp-on-zoom fix gated on the on-device root-cause probe (plan M3). Plans: ralplan-consensus plan at docs/plans/2026-06-22-badnote-pen-polish.md (Architect APPROVE-WITH-MUST-FIX M1-M4 + Critic ITERATE->APPROVE). Tests: 58/58 pass incl. shared-thinning invariant + thinning-affects-outline + tilt-adapter round-trip. flutter analyze clean; linux debug build OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 02:10:05 +08:00
path.lineTo(outlinePoints[i].dx, outlinePoints[i].dy);
Fix bugs across app + server, optimize UI/UX, add Gitea CI 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>
2026-06-21 03:18:00 +08:00
}
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;
}
}