Files
BadNote/lib/editor/canvas/pen_editor_screen.dart
Akiba So 3febbd1431
All checks were successful
CI / Windows build (push) Successful in 8m22s
feat(editor): pen canvas + Material You UI
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.
2026-06-21 22:04:49 +08:00

431 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
),
);
}
}