Files
BadNote/lib/widgets/pdf_annotation_layer.dart
Akiba So 72428dc075
Some checks failed
CI / Test (Server, optional) (push) Failing after 2m10s
Windows Build / Build Windows (x64) (push) Failing after 29s
CI / Test (Flutter, Linux) (push) Has been cancelled
CI / Analyze (Flutter) (push) Has been cancelled
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

163 lines
5.1 KiB
Dart

import 'package:flutter/material.dart';
import '../models/ink_point.dart';
import '../models/ink_stroke.dart';
import '../models/pen_tool.dart';
import '../widgets/ink_canvas.dart';
/// Transparent overlay widget positioned on top of the PDF viewer.
///
/// Reuses the existing [InkCanvas] widget for ink rendering.
/// Coordinates are normalized to [0, 1] relative to the overlay size,
/// enabling correct mapping to PDF page coordinates during export.
class PdfAnnotationLayer 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 bool filled;
final InteractionMode interactionMode;
final int rotation;
const PdfAnnotationLayer({
super.key,
required this.strokes,
this.onStrokeComplete,
this.onErase,
this.tool = PenTool.pen,
this.color = Colors.black,
this.strokeWidth = 2.0,
this.filled = false,
this.interactionMode = InteractionMode.draw,
this.rotation = 0,
});
@override
State<PdfAnnotationLayer> createState() => _PdfAnnotationLayerState();
}
class _PdfAnnotationLayerState extends State<PdfAnnotationLayer> {
Size _canvasSize = Size.zero;
/// Applies inverse rotation to normalized coordinates for rendering.
/// Converts from stored (possibly rotated) coords back to display coords.
Offset _inverseRotate(double nx, double ny, int rotation) {
switch (rotation % 360) {
case 90:
return Offset(1.0 - ny, nx);
case 180:
return Offset(1.0 - nx, 1.0 - ny);
case 270:
return Offset(ny, 1.0 - nx);
default:
return Offset(nx, ny);
}
}
/// Applies forward rotation to normalized coordinates before storage.
/// Converts from display coords to the canonical rotated representation.
Offset _forwardRotate(double nx, double ny, int rotation) {
switch (rotation % 360) {
case 90:
return Offset(ny, 1.0 - nx);
case 180:
return Offset(1.0 - nx, 1.0 - ny);
case 270:
return Offset(1.0 - ny, nx);
default:
return Offset(nx, ny);
}
}
/// Scales a stroke's points from normalized [0, 1] coordinates to
/// the current canvas pixel coordinates for rendering.
/// Applies inverse rotation before scaling so strokes render correctly
/// on a rotated page.
List<InkStroke> get _scaledStrokes {
if (_canvasSize == Size.zero) return widget.strokes;
return widget.strokes.map((stroke) {
return InkStroke(
id: stroke.id,
points: stroke.points.map((pt) {
final rotated = _inverseRotate(pt.x, pt.y, widget.rotation);
return InkPoint(
x: rotated.dx * _canvasSize.width,
y: rotated.dy * _canvasSize.height,
pressure: pt.pressure,
tilt: pt.tilt,
timestamp: pt.timestamp,
pointerDeviceKind: pt.pointerDeviceKind,
);
}).toList(),
tool: stroke.tool,
color: stroke.color,
strokeWidth: stroke.strokeWidth,
createdAt: stroke.createdAt,
filled: stroke.filled,
textContent: stroke.textContent,
fontSize: stroke.fontSize,
);
}).toList();
}
/// Normalizes a stroke's points from canvas pixel coordinates to
/// [0, 1] relative to the overlay size.
/// Applies forward rotation before storage so the canonical representation
/// accounts for the current page rotation.
InkStroke _normalizeStroke(InkStroke stroke) {
if (_canvasSize == Size.zero) return stroke;
return InkStroke(
id: stroke.id,
points: stroke.points.map((pt) {
final nx = pt.x / _canvasSize.width;
final ny = pt.y / _canvasSize.height;
final rotated = _forwardRotate(nx, ny, widget.rotation);
return InkPoint(
x: rotated.dx,
y: rotated.dy,
pressure: pt.pressure,
tilt: pt.tilt,
timestamp: pt.timestamp,
pointerDeviceKind: pt.pointerDeviceKind,
);
}).toList(),
tool: stroke.tool,
color: stroke.color,
strokeWidth: stroke.strokeWidth,
createdAt: stroke.createdAt,
filled: stroke.filled,
textContent: stroke.textContent,
fontSize: stroke.fontSize,
);
}
void _onStrokeComplete(InkStroke stroke) {
widget.onStrokeComplete?.call(_normalizeStroke(stroke));
}
void _onErase(String strokeId, List<InkStroke> replacements) {
widget.onErase?.call(strokeId, replacements.map(_normalizeStroke).toList());
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
_canvasSize = Size(constraints.maxWidth, constraints.maxHeight);
return InkCanvas(
strokes: _scaledStrokes,
onStrokeComplete: _onStrokeComplete,
onErase: _onErase,
tool: widget.tool,
color: widget.color,
strokeWidth: widget.strokeWidth,
filled: widget.filled,
interactionMode: widget.interactionMode,
);
},
);
}
}