All checks were successful
CI / Windows build (push) Successful in 8m22s
Clean-room reimplementation of Saber's input model: we own the gesture pipeline so pen draws with real pressure (perfect_freehand), two-finger pinch zooms/pans, and palm is rejected (stylus-priority, 2nd-pointer cancels stroke). pdfrx renders one page at a time (PdfPageView, no gestures) under a shared transform; page-based nav. Material You theme via dynamic_color (system accent + seed fallback) and a floating tonal tool palette + page pill. Old pdfrx-overlay spike no longer wired.
431 lines
13 KiB
Dart
431 lines
13 KiB
Dart
// lib/editor/canvas/pen_editor_screen.dart
|
||
//
|
||
// Page-based pen-first PDF editor. Opens a PDF with pdfrx's document API,
|
||
// shows ONE page at a time as a bitmap (PdfPageView — a per-page widget that
|
||
// renders to an image and does NOT capture pan/zoom gestures), overlaid by the
|
||
// ink layer. Both share one transform via PenCanvas. Prev/Next + jump-to-page.
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:pdfrx/pdfrx.dart';
|
||
|
||
import 'pen_canvas.dart';
|
||
import 'pen_stroke.dart';
|
||
|
||
class PenEditorScreen extends StatefulWidget {
|
||
const PenEditorScreen({super.key, required this.pdfPath});
|
||
|
||
final String pdfPath;
|
||
|
||
@override
|
||
State<PenEditorScreen> createState() => _PenEditorScreenState();
|
||
}
|
||
|
||
class _PenEditorScreenState extends State<PenEditorScreen> {
|
||
PdfDocument? _document;
|
||
Object? _openError;
|
||
|
||
/// 0-based current page index.
|
||
int _pageIndex = 0;
|
||
|
||
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||
|
||
/// One shared transform for the current page; reset on page change so each
|
||
/// page opens fit-to-view.
|
||
final TransformationController _transform = TransformationController();
|
||
|
||
// Tool state.
|
||
CanvasTool _tool = CanvasTool.pen;
|
||
Color _color = Colors.black;
|
||
bool _allowFingerDrawing = false;
|
||
|
||
/// Pen width as a fraction of page width.
|
||
static const double _penWidthFraction = 0.004;
|
||
static const double _highlighterWidthFraction = 0.02;
|
||
|
||
static const List<Color> _palette = [
|
||
Colors.black,
|
||
Colors.red,
|
||
Colors.blue,
|
||
Colors.green,
|
||
Colors.orange,
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_open();
|
||
}
|
||
|
||
Future<void> _open() async {
|
||
try {
|
||
final doc = await PdfDocument.openFile(widget.pdfPath);
|
||
if (!mounted) {
|
||
doc.dispose();
|
||
return;
|
||
}
|
||
setState(() => _document = doc);
|
||
} catch (e) {
|
||
if (mounted) setState(() => _openError = e);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_document?.dispose();
|
||
_transform.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
List<PenStroke> get _currentStrokes =>
|
||
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
|
||
|
||
void _commitStroke(PenStroke stroke) {
|
||
setState(() {
|
||
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]).add(stroke);
|
||
});
|
||
}
|
||
|
||
void _eraseStroke(int index) {
|
||
setState(() {
|
||
final list = _strokesByPage[_pageIndex];
|
||
if (list != null && index >= 0 && index < list.length) {
|
||
list.removeAt(index);
|
||
}
|
||
});
|
||
}
|
||
|
||
void _goToPage(int index) {
|
||
final doc = _document;
|
||
if (doc == null) return;
|
||
final clamped = index.clamp(0, doc.pages.length - 1);
|
||
if (clamped == _pageIndex) return;
|
||
setState(() {
|
||
_pageIndex = clamped;
|
||
_transform.value = Matrix4.identity();
|
||
});
|
||
}
|
||
|
||
Future<void> _promptJumpToPage() async {
|
||
final doc = _document;
|
||
if (doc == null) return;
|
||
final controller = TextEditingController(text: '${_pageIndex + 1}');
|
||
final result = await showDialog<int>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Go to page'),
|
||
content: TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
keyboardType: TextInputType.number,
|
||
decoration: InputDecoration(hintText: '1 – ${doc.pages.length}'),
|
||
onSubmitted: (v) =>
|
||
Navigator.of(context).pop(int.tryParse(v.trim())),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
TextButton(
|
||
onPressed: () =>
|
||
Navigator.of(context).pop(int.tryParse(controller.text.trim())),
|
||
child: const Text('Go'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (result != null) _goToPage(result - 1);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
body: Stack(
|
||
children: [
|
||
Positioned.fill(child: _buildBody()),
|
||
// Floating Material You tool palette (top-center).
|
||
SafeArea(
|
||
child: Align(
|
||
alignment: Alignment.topCenter,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: _buildToolPalette(),
|
||
),
|
||
),
|
||
),
|
||
// Floating page-control pill (bottom-center).
|
||
if (_document != null)
|
||
SafeArea(
|
||
child: Align(
|
||
alignment: Alignment.bottomCenter,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: _buildPagePill(),
|
||
),
|
||
),
|
||
),
|
||
// Back button (top-left).
|
||
SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: _RoundIconButton(
|
||
icon: Icons.arrow_back,
|
||
tooltip: 'Back',
|
||
onPressed: () => Navigator.of(context).maybePop(),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBody() {
|
||
if (_openError != null) {
|
||
return Center(child: Text('Failed to open PDF:\n$_openError'));
|
||
}
|
||
final doc = _document;
|
||
if (doc == null) {
|
||
return const Center(child: CircularProgressIndicator());
|
||
}
|
||
if (doc.pages.isEmpty) {
|
||
return const Center(child: Text('PDF has no pages.'));
|
||
}
|
||
|
||
final page = doc.pages[_pageIndex];
|
||
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// Fit the page rectangle into the available viewport at scale 1.0; the
|
||
// InteractiveViewer then zooms/pans from there. Ink normalized coords
|
||
// map onto this rectangle.
|
||
final fit = (constraints.maxWidth / page.width)
|
||
.clamp(0.0, double.infinity);
|
||
final fitH = constraints.maxHeight / page.height;
|
||
final scale = fit < fitH ? fit : fitH;
|
||
final pageSize = Size(page.width * scale, page.height * scale);
|
||
|
||
return Center(
|
||
child: PenCanvas(
|
||
key: ValueKey(_pageIndex),
|
||
pageSize: pageSize,
|
||
strokes: _currentStrokes,
|
||
transformationController: _transform,
|
||
tool: _tool,
|
||
color: _color,
|
||
strokeWidth: _tool == CanvasTool.highlighter
|
||
? _highlighterWidthFraction
|
||
: _penWidthFraction,
|
||
allowFingerDrawing: _allowFingerDrawing,
|
||
onStrokeComplete: _commitStroke,
|
||
onEraseStroke: _eraseStroke,
|
||
pageWidget: PdfPageView(
|
||
document: doc,
|
||
pageNumber: _pageIndex + 1,
|
||
// Fill the SizedBox exactly so ink aligns to the page rect (no
|
||
// internal letterboxing offset).
|
||
pageSizeCallback: (biggest, page, rotation) => biggest,
|
||
decoration: const BoxDecoration(color: Colors.white),
|
||
backgroundColor: Colors.white,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Floating Material You tool palette: a tonal rounded surface holding the
|
||
/// tools, color dots, and finger-drawing toggle.
|
||
Widget _buildToolPalette() {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return Material(
|
||
color: cs.surfaceContainerHigh,
|
||
elevation: 3,
|
||
borderRadius: BorderRadius.circular(28),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_ToolButton(
|
||
icon: Icons.edit_outlined,
|
||
selected: _tool == CanvasTool.pen,
|
||
tooltip: 'Pen',
|
||
onPressed: () => setState(() => _tool = CanvasTool.pen),
|
||
),
|
||
_ToolButton(
|
||
icon: Icons.brush_outlined,
|
||
selected: _tool == CanvasTool.highlighter,
|
||
tooltip: 'Highlighter',
|
||
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||
),
|
||
_ToolButton(
|
||
icon: Icons.cleaning_services_outlined,
|
||
selected: _tool == CanvasTool.eraser,
|
||
tooltip: 'Eraser',
|
||
onPressed: () => setState(() => _tool = CanvasTool.eraser),
|
||
),
|
||
_Divider(cs: cs),
|
||
for (final c in _palette) _colorDot(c, cs),
|
||
_Divider(cs: cs),
|
||
_ToolButton(
|
||
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
||
selected: _allowFingerDrawing,
|
||
tooltip: _allowFingerDrawing
|
||
? 'Finger drawing ON'
|
||
: 'Finger drawing OFF (pen only)',
|
||
onPressed: () =>
|
||
setState(() => _allowFingerDrawing = !_allowFingerDrawing),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _colorDot(Color c, ColorScheme cs) {
|
||
final selected = _color == c;
|
||
return GestureDetector(
|
||
onTap: () => setState(() => _color = c),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
width: 28,
|
||
height: 28,
|
||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||
decoration: BoxDecoration(
|
||
color: c,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: selected ? cs.primary : cs.outlineVariant,
|
||
width: selected ? 3 : 1.5,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Floating page-control pill: prev / "n / total" / next.
|
||
Widget _buildPagePill() {
|
||
final doc = _document!;
|
||
final cs = Theme.of(context).colorScheme;
|
||
return Material(
|
||
color: cs.surfaceContainerHigh,
|
||
elevation: 3,
|
||
borderRadius: BorderRadius.circular(28),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
IconButton(
|
||
tooltip: 'Previous page',
|
||
icon: const Icon(Icons.chevron_left),
|
||
onPressed: _pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
||
),
|
||
TextButton(
|
||
onPressed: _promptJumpToPage,
|
||
child: Text(
|
||
'${_pageIndex + 1} / ${doc.pages.length}',
|
||
style: TextStyle(
|
||
color: cs.onSurface,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
IconButton(
|
||
tooltip: 'Next page',
|
||
icon: const Icon(Icons.chevron_right),
|
||
onPressed: _pageIndex < doc.pages.length - 1
|
||
? () => _goToPage(_pageIndex + 1)
|
||
: null,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A Material You toggle-style icon button for the tool palette.
|
||
class _ToolButton extends StatelessWidget {
|
||
const _ToolButton({
|
||
required this.icon,
|
||
required this.selected,
|
||
required this.tooltip,
|
||
required this.onPressed,
|
||
});
|
||
|
||
final IconData icon;
|
||
final bool selected;
|
||
final String tooltip;
|
||
final VoidCallback onPressed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return Tooltip(
|
||
message: tooltip,
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(20),
|
||
onTap: onPressed,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: selected ? cs.secondaryContainer : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Icon(
|
||
icon,
|
||
size: 22,
|
||
color: selected ? cs.onSecondaryContainer : cs.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Divider extends StatelessWidget {
|
||
const _Divider({required this.cs});
|
||
final ColorScheme cs;
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
width: 1,
|
||
height: 24,
|
||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||
color: cs.outlineVariant,
|
||
);
|
||
}
|
||
|
||
/// A round, tonal icon button (used for the floating back button).
|
||
class _RoundIconButton extends StatelessWidget {
|
||
const _RoundIconButton({
|
||
required this.icon,
|
||
required this.tooltip,
|
||
required this.onPressed,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String tooltip;
|
||
final VoidCallback onPressed;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cs = Theme.of(context).colorScheme;
|
||
return Material(
|
||
color: cs.surfaceContainerHigh,
|
||
elevation: 3,
|
||
shape: const CircleBorder(),
|
||
child: IconButton(
|
||
tooltip: tooltip,
|
||
icon: Icon(icon),
|
||
color: cs.onSurfaceVariant,
|
||
onPressed: onPressed,
|
||
),
|
||
);
|
||
}
|
||
}
|