163 lines
5.1 KiB
Dart
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,
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|