Files
BadNote/lib/editor/canvas/pen_editor_screen.dart
Akiba So 0feca74278
All checks were successful
CI / Windows build (push) Successful in 14m54s
feat(pen): extensible brush model (4 brushes)
Replace the 2-tool ink system with a data-driven, Krita-style
BrushProfile (lib/editor/engine/brush.dart). Adding a brush is a
const map entry, not render-path branching.

Four presets from the rnote/krita spec:
- fountain pen: quadratic (p^2) pressure, wide dynamic width
- ballpoint:    near-constant width (thinning 0.15)
- highlighter:  flat width, square caps
- pencil:       sqrt(p) pressure, moderate width

Pressure is pre-warped per brush via PressureCurve(gamma) before
perfect_freehand; geometry fields (thinning/streamline/smoothing/
caps) flow through the shared stroke recipe so the PDF overlay and
the note/slide PenCanvas both honor the brush. Brush kind is now
persisted on the stroke model. Picker added to all three toolbars.

Opacity/multiply and pencil grain are carried as data but not yet
composited (TODO brush-opacity / brush-texture); this increment is
width + pressure-curve differentiation. analyze clean, 283 tests.
2026-06-24 11:13:43 +08:00

1090 lines
39 KiB
Dart

// lib/editor/canvas/pen_editor_screen.dart
//
// Pen-first PDF editor built on pdfrx's REAL vector PdfViewer (continuous
// scroll, native pan/zoom, selectable text), NOT the old single-page bitmap
// (PdfPageView). Three glued-to-page layers ride on top of the viewer:
//
// 1. Per-page ink overlay (pageOverlaysBuilder): committed strokes + the
// live in-progress stroke + stored text highlights, all in NORMALIZED
// page coords so they stay pinned under scroll/zoom.
// 2. A viewer-level PenCaptureRegion (viewerOverlayBuilder) that captures
// stylus events ONLY when a pen tool is active; touch/mouse fall through
// to pdfrx for scroll/zoom/text-selection. Pen samples are mapped
// global → document → (pageIndex, normalized) via the controller.
// 3. The PdfViewer itself owns text selection; a "select text" tool disables
// pen capture so the pen drives pdfrx text selection, and a
// "highlight selection" action turns the current selection into stored
// normalized highlight rects.
//
// Persistence (strokes) reuses the existing EditorRepository / SaveScheduler
// wiring verbatim. Highlights are in-memory only for now — see
// TODO(persist-highlights).
import 'package:flutter/gestures.dart' show PointerDeviceKind;
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../l10n/app_localizations.dart';
import '../../services/database_service.dart';
import '../engine/brush.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_simplify.dart';
import '../engine/undo_stack.dart';
import '../input/diagnostic_logger.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pressure_curve.dart'
show PressureCurve, kNaturalPressureFloor;
import '../pdf/pen_capture_region.dart';
import '../persistence/editor_repository.dart';
import '../persistence/save_scheduler.dart';
import '../ui/pen_settings_page.dart';
import '../ui/thumbnail_grid.dart';
import 'ink_painters.dart' show buildStrokePath;
import 'input_diagnostics.dart';
import 'pen_canvas.dart' show CanvasTool;
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
/// Stable deterministic document-id for a file path (djb2 hash → hex).
///
/// Produces a fixed-length hex string from the path so the id is filesystem-
/// independent (no slashes, spaces, or non-ASCII characters) and stable across
/// restarts. Collisions are astronomically unlikely for a single-user app.
String _documentIdFromPath(String path) {
var hash = 5381;
for (final c in path.codeUnits) {
hash = ((hash << 5) + hash + c) & 0xFFFFFFFF;
}
return hash.toRadixString(16).padLeft(8, '0');
}
class PenEditorScreen extends StatefulWidget {
const PenEditorScreen({
super.key,
required this.pdfPath,
this.initialPage = 0,
});
final String pdfPath;
/// 0-based page to open on (e.g. a search-result jump). Clamped to the
/// document's page range once it loads.
final int initialPage;
@override
State<PenEditorScreen> createState() => _PenEditorScreenState();
}
class _PenEditorScreenState extends State<PenEditorScreen> {
/// pdfrx viewer controller. Owns native pan/zoom and gives us the page
/// layout rects + coordinate conversions used to map pen events and to
/// re-project normalized strokes back to viewer pixels.
final PdfViewerController _controller = PdfViewerController();
bool _viewerReady = false;
/// Total page count (0 until the document is laid out).
int _pageCount = 0;
/// 0-based current page index (the page pdfrx reports as current). Drives the
/// page pill + thumbnail highlight.
int _pageIndex = 0;
/// Strokes per page, keyed by 0-based page index (normalized coords).
final Map<int, List<PenStroke>> _strokesByPage = {};
/// Text highlights per page, keyed by 0-based page index. Each Rect is in
/// NORMALIZED page coords (left/top/right/bottom in [0,1]) so it stays glued
/// under zoom. In-memory only for now — see TODO(persist-highlights).
final Map<int, List<Rect>> _highlightsByPage = {};
/// Per-page undo/redo history. Snapshot-before-change discipline: the
/// pre-mutation stroke list is recorded before each commit/erase.
final Map<int, UndoStack<List<PenStroke>>> _undo = {};
UndoStack<List<PenStroke>> _undoFor(int page) =>
_undo.putIfAbsent(page, () => UndoStack<List<PenStroke>>());
/// Pen input configuration (widths, finger drawing, button actions).
/// Loaded asynchronously in initState; null until ready.
PenConfigController? _penConfig;
// ── Persistence ────────────────────────────────────────────────────────────
/// Stable document-id derived from the PDF file path.
late final String _documentId;
SaveScheduler? _saveScheduler;
/// Repaint signal for the page overlays. Bumped on every pen move/commit/erase
/// and on every viewer transform so the per-page CustomPaint re-projects the
/// live stroke + committed ink to the current zoom.
final ValueNotifier<int> _overlayRepaint = ValueNotifier<int>(0);
void _bumpOverlay() => _overlayRepaint.value++;
// ── Live stroke state (viewer-level pen capture) ────────────────────────────
/// The page index the in-progress stroke belongs to (the page of its first
/// point). A stroke lives on exactly ONE page; samples on other pages are
/// ignored. Null when idle.
int? _liveStrokePage;
/// In-progress stroke points (normalized to [_liveStrokePage]).
final List<PenPoint> _livePoints = [];
/// Live stroke snapshot for the page overlay; null when idle.
PenStroke? _liveStroke;
/// Latest pen-event debug readout — shown only when the diagnostic toggle is
/// on, to inspect what Windows delivers.
String _penDebug = '';
bool _showPenDebug = false;
double _peakNorm = 0;
// Tool state.
CanvasTool _tool = CanvasTool.pen;
/// Selected brush for the PEN tool (fountain/ballpoint/pencil). The
/// highlighter tool always uses [BrushKind.highlighter]. Local state only for
/// this increment (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
/// When true the "select text" tool is active: pen capture is disabled so the
/// pen falls through to pdfrx for native text selection.
bool _selectTextMode = false;
Color _color = Colors.black;
bool _allowFingerDrawing = false;
/// Whether the viewer currently has a non-empty text selection (drives the
/// "highlight selection" action's enabled state).
bool _hasSelection = false;
/// Pen width as a fraction of page width (base; pressure thins it down).
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = [
Colors.black,
Colors.red,
Colors.blue,
Colors.green,
Colors.orange,
];
/// True when a PEN tool (pen/highlighter/eraser) is active — pen capture is on.
/// False in select-text mode so the pen reaches pdfrx text selection.
bool get _penCaptureEnabled => !_selectTextMode;
/// True when the eraser tool is active.
bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode;
@override
void initState() {
super.initState();
_documentId = _documentIdFromPath(widget.pdfPath);
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
// No-op on platforms without the plugin (W3).
PenInputService.instance.start();
_initPersistence();
_initPenConfig();
}
Future<void> _initPenConfig() async {
final controller = await PenConfigController.load();
if (!mounted) {
controller.dispose();
return;
}
controller.addListener(_onPenConfigChanged);
setState(() {
_penConfig = controller;
_allowFingerDrawing = controller.value.fingerDrawing;
});
}
void _onPenConfigChanged() {
if (mounted) setState(() {});
}
Future<void> _initPersistence() async {
final service = await DatabaseService.getInstance();
if (!mounted) return;
final repo = await EditorRepository.fromService(service);
final scheduler = SaveScheduler(repo);
if (!mounted) {
scheduler.dispose();
return;
}
_saveScheduler = scheduler;
await _loadPersistedStrokes(repo);
}
/// Load all persisted strokes for [_documentId] and populate [_strokesByPage].
Future<void> _loadPersistedStrokes(EditorRepository repo) async {
final hosted = await repo.loadDocument(_documentId);
if (!mounted) return;
final loaded = <int, List<PenStroke>>{};
for (final entry in hosted.entries) {
final pageIndex = _pageIndexFromHostId(entry.key);
if (pageIndex == null) continue;
loaded[pageIndex] = entry.value
.map((es) => PenStroke(
points: es.points
.map((ep) => PenPoint(ep.x, ep.y, ep.pressure, tilt: ep.tilt))
.toList(),
color: es.color,
width: es.width,
kind: es.tool == EditorTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen,
// Brush isn't persisted yet (TODO(brush-persist)); derive it
// from the tool so a loaded highlighter still renders with the
// highlighter brush (flat width), and pens fall back to the
// fountainPen default.
brush: es.tool == EditorTool.highlighter
? BrushKind.highlighter
: BrushKind.fountainPen,
))
.toList();
}
if (loaded.isNotEmpty) {
setState(() {
for (final entry in loaded.entries) {
_strokesByPage[entry.key] = entry.value;
}
});
_bumpOverlay();
}
}
/// Extract the page index from a host_id of the form
/// `"doc:<documentId>:page:<pageIndex>"`.
int? _pageIndexFromHostId(String hostId) {
const marker = ':page:';
final idx = hostId.lastIndexOf(marker);
if (idx == -1) return null;
return int.tryParse(hostId.substring(idx + marker.length));
}
@override
void dispose() {
// Flush any pending scheduled saves before tearing down.
final scheduler = _saveScheduler;
if (scheduler != null) {
scheduler.flush(); // fire-and-forget; DB write continues in isolate
scheduler.dispose();
}
_overlayRepaint.dispose();
_penConfig?.dispose();
PenInputService.instance.stop();
DiagnosticLogger.instance.stop();
super.dispose();
}
// ── Stroke persistence (reused verbatim from the bitmap editor) ────────────
void _commitStroke(int pageIndex, PenStroke stroke) {
_undoFor(pageIndex)
.record(List<PenStroke>.of(_strokesByPage[pageIndex] ?? const []));
setState(() {
// New list identity so the overlay painter sees a change.
_strokesByPage[pageIndex] = [
...?_strokesByPage[pageIndex],
stroke,
];
});
final snapshot = List<PenStroke>.of(_strokesByPage[pageIndex]!);
_schedulePageSave(pageIndex, snapshot);
_bumpOverlay();
}
/// Replace committed stroke [index] on [pageIndex] with its surviving pieces
/// after a partial (segment) erase. An empty [replacements] removes it.
void _eraseStroke(int pageIndex, int index, List<PenStroke> replacements) {
final list = _strokesByPage[pageIndex];
final willMutate = list != null && index >= 0 && index < list.length;
if (willMutate) {
_undoFor(pageIndex).record(List<PenStroke>.of(list));
}
setState(() {
if (list != null && index >= 0 && index < list.length) {
final next = List<PenStroke>.of(list)
..replaceRange(index, index + 1, replacements);
_strokesByPage[pageIndex] = next;
}
});
final current = _strokesByPage[pageIndex];
final snapshot =
current != null ? List<PenStroke>.of(current) : <PenStroke>[];
_schedulePageSave(pageIndex, snapshot);
_bumpOverlay();
}
/// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler.
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
final scheduler = _saveScheduler;
if (scheduler == null) return;
final editorStrokes = strokes
.map((s) => simplifyStroke(EditorStroke.fromPenStroke(s)))
.toList();
scheduler.schedule(
'page',
EditorRepository.pageHostId(_documentId, pageIndex),
editorStrokes,
);
}
void _performUndo() {
final stack = _undoFor(_pageIndex);
if (!stack.canUndo) return;
final current = List<PenStroke>.of(_strokesByPage[_pageIndex] ?? const []);
final snapshot = stack.undo(current);
if (snapshot == null) return;
setState(() {
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
});
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
_bumpOverlay();
}
void _performRedo() {
final stack = _undoFor(_pageIndex);
if (!stack.canRedo) return;
final snapshot = stack.redo();
if (snapshot == null) return;
setState(() {
_strokesByPage[_pageIndex] = List<PenStroke>.of(snapshot);
});
_schedulePageSave(_pageIndex, List<PenStroke>.of(snapshot));
_bumpOverlay();
}
// ── Pen capture (viewer-level) ─────────────────────────────────────────────
/// Normalize stylus pressure to [0,1] shaped by the configured curve, or null
/// when the device reports no usable pressure range (freehand simulates it).
double? _normalizedPressure(PointerEvent event) {
final raw = _rawNormalizedPressure(event);
if (raw == null) return null;
// PenConfig exposes gamma but not floor; use the shared natural floor (the
// bitmap editor did the same — it never sourced floor from config). The
// gamma is the BRUSH's pressure warp (fountain p² / pencil √p / linear),
// superseding the legacy config gamma — see TODO(brush-pressure-knob).
const floor = kNaturalPressureFloor;
final gamma = brushProfileFor(_currentBrush()).pressureGamma;
return PressureCurve(floor: floor, gamma: gamma).apply(raw);
}
double? _rawNormalizedPressure(PointerEvent event) {
final range = event.pressureMax - event.pressureMin;
if (range > 0.0001) {
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
}
if (event.pressure > 0.0 && event.pressure < 1.0) {
return event.pressure;
}
return null;
}
/// Map a global pen position to (pageIndex, normalized-in-page) using the
/// controller's document-space page layout rects. Returns null if outside
/// every page box or the viewer isn't ready.
({int page, Offset normalized})? _documentToPage(Offset global) {
if (!_controller.isReady) return null;
final doc = _controller.globalToDocument(global);
if (doc == null) return null;
final rects = _controller.layout.pageLayouts;
for (var i = 0; i < rects.length; i++) {
final r = rects[i];
if (r.contains(doc)) {
final nx = ((doc.dx - r.left) / r.width).clamp(0.0, 1.0);
final ny = ((doc.dy - r.top) / r.height).clamp(0.0, 1.0);
return (page: i, normalized: Offset(nx, ny));
}
}
return null;
}
void _onPenEvent(PointerEvent event) {
if (_isStylus(event.kind)) _emitPenDebug(event);
final hit = _documentToPage(event.position);
if (event is PointerDownEvent) {
if (hit == null) return;
if (_isEraser) {
_liveStrokePage = hit.page;
_eraseAt(hit.page, hit.normalized);
return;
}
_liveStrokePage = hit.page;
_livePoints
..clear()
..add(PenPoint(hit.normalized.dx, hit.normalized.dy,
_normalizedPressure(event)));
_updateLiveStroke();
} else if (event is PointerMoveEvent) {
final page = _liveStrokePage;
if (page == null) return;
if (_isEraser) {
// Erase only against the page the gesture started on; ignore drift.
if (hit != null && hit.page == page) {
_eraseAt(page, hit.normalized);
}
return;
}
// A stroke belongs to ONE page: ignore samples on a different page.
if (hit == null || hit.page != page) return;
_livePoints.add(PenPoint(
hit.normalized.dx, hit.normalized.dy, _normalizedPressure(event)));
_updateLiveStroke();
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_endStroke(commit: event is PointerUpEvent);
}
}
void _updateLiveStroke() {
final page = _liveStrokePage;
if (page == null || _livePoints.isEmpty) return;
_liveStroke = PenStroke(
points: List.of(_livePoints),
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: _currentKind(),
brush: _currentBrush(),
);
_bumpOverlay();
}
void _endStroke({required bool commit}) {
final page = _liveStrokePage;
if (page != null && commit && !_isEraser && _livePoints.isNotEmpty) {
// A single tap → tiny dot is allowed (perfect_freehand renders a dot for
// a 1-point stroke).
_commitStroke(
page,
PenStroke(
points: List.of(_livePoints),
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: _currentKind(),
brush: _currentBrush(),
),
);
}
_liveStrokePage = null;
_livePoints.clear();
_liveStroke = null;
_bumpOverlay();
}
/// Partial (segment) erase on [pageIndex]: find the first committed stroke the
/// eraser circle touches and replace it with its surviving pieces.
void _eraseAt(int pageIndex, Offset normalized) {
final strokes = _strokesByPage[pageIndex];
if (strokes == null || strokes.isEmpty) return;
final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
final wholeStroke = _penConfig?.value.eraserWholeStroke ?? false;
final aspect = _pageAspect(pageIndex);
for (var i = strokes.length - 1; i >= 0; i--) {
final stroke = strokes[i];
if (!strokeHit(stroke, normalized.dx, normalized.dy, radius,
aspect: aspect)) {
continue;
}
final pieces = wholeStroke
? const <PenStroke>[]
: splitStrokeByCircle(stroke, normalized.dx, normalized.dy, radius,
aspect: aspect);
if (pieces.length == 1 && identical(pieces.first, stroke)) return;
_eraseStroke(pageIndex, i, pieces);
return;
}
}
/// Page aspect (height / width) so the eraser circle stays round on screen.
double _pageAspect(int pageIndex) {
if (!_controller.isReady) return 1.0;
final rects = _controller.layout.pageLayouts;
if (pageIndex < 0 || pageIndex >= rects.length) return 1.0;
final r = rects[pageIndex];
return r.width <= 0 ? 1.0 : r.height / r.width;
}
bool _isStylus(PointerDeviceKind kind) =>
kind == PointerDeviceKind.stylus ||
kind == PointerDeviceKind.invertedStylus;
double _currentStrokeWidth() => _tool == CanvasTool.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
PenStrokeKind _currentKind() => _tool == CanvasTool.highlighter
? PenStrokeKind.highlighter
: PenStrokeKind.pen;
/// Brush in effect: highlighter tool ⇒ highlighter brush, else the selected
/// pen brush. Drives both the capture-time pressure warp and render geometry.
BrushKind _currentBrush() => _tool == CanvasTool.highlighter
? BrushKind.highlighter
: _penBrush;
Color _currentColor() => _tool == CanvasTool.highlighter
? _color.withAlpha(0x80)
: _color;
void _emitPenDebug(PointerEvent event) {
if (!_showPenDebug) return;
final norm = _normalizedPressure(event);
if (norm != null && norm > _peakNorm) _peakNorm = norm;
setState(() {
_penDebug = '${event.kind.name} raw=${event.pressure.toStringAsFixed(1)}'
'/${event.pressureMax.toStringAsFixed(0)} '
'norm=${norm?.toStringAsFixed(3) ?? "null"} '
'peak=${_peakNorm.toStringAsFixed(3)} '
'btn=${event.buttons} tilt=${event.tilt.toStringAsFixed(2)}'
'\n${PenInputService.instance.debugSummary}';
});
}
// ── Text selection → highlight ─────────────────────────────────────────────
void _onTextSelectionChange(PdfTextSelection selection) {
final has = selection.textSelectionPointRange != null;
if (has != _hasSelection && mounted) {
setState(() => _hasSelection = has);
}
}
/// Read the current text selection, convert each selected fragment's PDF
/// rectangle to a NORMALIZED page rect, store it in [_highlightsByPage], and
/// clear the selection so the highlight is visible.
Future<void> _highlightSelection() async {
if (!_controller.isReady) return;
final delegate = _controller.textSelectionDelegate;
final ranges = await delegate.getSelectedTextRanges();
if (!mounted || ranges.isEmpty) return;
final added = <int, List<Rect>>{};
final doc = _controller.document;
for (final range in ranges) {
final pageIndex = range.pageNumber - 1;
if (pageIndex < 0 || pageIndex >= doc.pages.length) continue;
final page = doc.pages[pageIndex];
final w = page.width;
final h = page.height;
if (w <= 0 || h <= 0) continue;
for (final frag in range.enumerateFragmentBoundingRects()) {
// PDF-page-coords rect → unscaled Flutter page-pixel rect (scale 1 ⇒
// page.size), then normalize by page size so it stays glued under zoom.
final r = frag.bounds.toRect(page: page);
final norm = Rect.fromLTRB(
(r.left / w).clamp(0.0, 1.0),
(r.top / h).clamp(0.0, 1.0),
(r.right / w).clamp(0.0, 1.0),
(r.bottom / h).clamp(0.0, 1.0),
);
if (norm.width <= 0 || norm.height <= 0) continue;
(added[pageIndex] ??= <Rect>[]).add(norm);
}
}
if (added.isEmpty) return;
setState(() {
for (final entry in added.entries) {
(_highlightsByPage[entry.key] ??= <Rect>[]).addAll(entry.value);
}
_hasSelection = false;
});
// TODO(persist-highlights): highlights are in-memory only; wire them into
// EditorRepository (a new host_kind) for cross-session persistence.
await delegate.clearTextSelection();
_bumpOverlay();
}
// ── Navigation / tools ─────────────────────────────────────────────────────
void _goToPage(int index) {
if (_pageCount == 0) return;
final clamped = index.clamp(0, _pageCount - 1);
_controller.goToPage(pageNumber: clamped + 1);
}
void _setTool(CanvasTool tool) {
setState(() {
_tool = tool;
_selectTextMode = false;
});
}
void _enableSelectText() {
setState(() => _selectTextMode = true);
}
void _toggleFingerDrawing() {
final next = !_allowFingerDrawing;
setState(() => _allowFingerDrawing = next);
_penConfig?.setFingerDrawing(next);
}
void _openThumbnails() {
final doc = _controller.isReady ? _controller.document : null;
if (doc == null) return;
showPageThumbnailSheet(
context,
document: doc,
currentPage: _pageIndex,
onPageSelected: _goToPage,
);
}
void _openPenSettings() {
final config = _penConfig;
if (config == null) return;
showPenSettingsSheet(context, config);
}
// ── Build ──────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
return Scaffold(
body: Stack(
children: [
Positioned.fill(child: _buildViewer()),
// 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 (_viewerReady && _pageCount > 0)
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: l.back,
onPressed: () => Navigator.of(context).maybePop(),
),
),
),
if (_showPenDebug) _buildDebugReadout(context),
],
),
);
}
Widget _buildViewer() {
return PdfViewer.file(
widget.pdfPath,
controller: _controller,
params: PdfViewerParams(
// Native vector text selection. Pen falls through to this only in
// select-text mode (PenCaptureRegion.captureEnabled == false).
textSelectionParams: PdfTextSelectionParams(
enabled: true,
onTextSelectionChange: _onTextSelectionChange,
),
onViewerReady: (document, controller) {
if (!mounted) return;
setState(() {
_viewerReady = true;
_pageCount = document.pages.length;
});
// Honor a requested initial page (search-result jump), clamped.
final target = widget.initialPage.clamp(0, _pageCount - 1);
if (target > 0) {
controller.goToPage(pageNumber: target + 1);
}
},
onPageChanged: (pageNumber) {
if (pageNumber == null || !mounted) return;
final idx = pageNumber - 1;
if (idx != _pageIndex) setState(() => _pageIndex = idx);
},
// (1) Per-page overlay: committed ink + live stroke + highlights, all in
// normalized page space scaled to the on-screen page rect.
pageOverlaysBuilder: (context, pageRectInViewer, page) {
final pageIndex = page.pageNumber - 1;
return [
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _PageOverlayPainter(
repaint: _overlayRepaint,
strokes: _strokesByPage[pageIndex] ?? const [],
highlights: _highlightsByPage[pageIndex] ?? const [],
liveStroke:
_liveStrokePage == pageIndex ? _liveStroke : null,
pageSize: pageRectInViewer.size,
thinning: _penConfig?.value.pressureSensitivity ??
kDefaultPenThinning,
),
),
),
),
];
},
// (2) Viewer-level pen capture. Stylus is captured ONLY when a pen tool
// is active; touch/mouse (and pen in select-text mode) fall through to
// pdfrx for scroll/zoom/text-selection.
viewerOverlayBuilder: (context, size, handleLinkTap) {
return [
Positioned.fill(
child: PenCaptureRegion(
captureEnabled: _penCaptureEnabled,
onPenEvent: _onPenEvent,
child: const IgnorePointer(child: SizedBox.expand()),
),
),
];
},
),
);
}
/// Floating Material You tool palette.
Widget _buildToolPalette() {
final cs = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
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: [
BrushPickerButton(
selected: _penBrush,
active: _tool == CanvasTool.pen && !_selectTextMode,
tooltip: l.brushPicker,
labelFor: (b) => brushLabel(b, l),
onSelected: (b) {
setState(() => _penBrush = b);
_setTool(CanvasTool.pen);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == CanvasTool.highlighter && !_selectTextMode,
tooltip: l.toolHighlighter,
onPressed: () => _setTool(CanvasTool.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _isEraser,
tooltip: l.toolEraser,
onPressed: () => _setTool(CanvasTool.eraser),
),
PaletteDivider(cs: cs),
// Text selection + highlight (real vector text).
ToolButton(
icon: Icons.text_fields,
selected: _selectTextMode,
tooltip: l.toolSelectText,
onPressed: _enableSelectText,
),
ToolButton(
icon: Icons.highlight,
selected: false,
tooltip: l.actionHighlightSelection,
onPressed: _hasSelection ? _highlightSelection : null,
),
PaletteDivider(cs: cs),
// Undo / redo (per page).
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: l.actionUndo,
onPressed: _undoFor(_pageIndex).canUndo ? _performUndo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: l.actionRedo,
onPressed: _undoFor(_pageIndex).canRedo ? _performRedo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
PaletteDivider(cs: cs),
ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip:
_allowFingerDrawing ? l.fingerDrawingOn : l.fingerDrawingOff,
onPressed: _toggleFingerDrawing,
),
ToolButton(
icon: Icons.grid_view,
selected: false,
tooltip: l.pages,
onPressed: _viewerReady ? _openThumbnails : null,
),
ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: l.penSettings,
onPressed: _penConfig != null ? _openPenSettings : null,
),
ToolButton(
icon: Icons.bug_report_outlined,
selected: _showPenDebug,
tooltip: l.inputDiagnostic,
onPressed: () {
final on = !_showPenDebug;
setState(() => _showPenDebug = on);
if (on) {
InputDiagnostics.instance.reset();
DiagnosticLogger.instance.start();
} else {
DiagnosticLogger.instance.stop();
}
},
),
],
),
),
);
}
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: a COMPACT pill (prev / "n / total" / next).
Widget _buildPagePill() {
final cs = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
final total = _pageCount;
final shown = _pageIndex + 1;
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: l.previousPage,
icon: const Icon(Icons.chevron_left),
onPressed:
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
),
TextButton(
onPressed: null,
child: Text(
l.pageOfPages(shown, total),
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: l.nextPage,
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)
: null,
),
],
),
),
);
}
Widget _buildDebugReadout(BuildContext context) {
return SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: Material(
color: Theme.of(context).colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: ListenableBuilder(
listenable: InputDiagnostics.instance,
builder: (context, _) {
final cs = Theme.of(context).colorScheme;
final d = InputDiagnostics.instance;
final tail = d.trace.length > 6
? d.trace.sublist(d.trace.length - 6)
: d.trace;
final mono = TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: cs.onInverseSurface);
final monoFaint = mono.copyWith(
fontSize: 10,
color: cs.onInverseSurface.withValues(alpha: 0.75));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_penDebug.isEmpty
? 'hover / draw with the pen…'
: _penDebug,
style: mono),
const SizedBox(height: 4),
Text(d.summary(), style: mono),
if (tail.isNotEmpty) ...[
const SizedBox(height: 4),
Text(tail.join('\n'), style: monoFaint),
],
const SizedBox(height: 4),
Text(
'log: ${DiagnosticLogger.instance.path ?? "(developer.log only)"}',
style: monoFaint),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () =>
InputDiagnostics.instance.reset(),
child: Text('Reset stats',
style:
TextStyle(color: cs.inversePrimary)),
),
),
],
);
},
),
),
),
),
),
),
);
}
}
/// Paints one page's overlay: text highlights (under), committed ink, then the
/// live in-progress stroke (over). Strokes are in normalized page coords; the
/// painter scales them to the on-screen page rect ([pageSize]) so they stay
/// glued to the page under pdfrx's native zoom/scroll.
class _PageOverlayPainter extends CustomPainter {
_PageOverlayPainter({
required Listenable repaint,
required this.strokes,
required this.highlights,
required this.liveStroke,
required this.pageSize,
required this.thinning,
}) : super(repaint: repaint);
final List<PenStroke> strokes;
final List<Rect> highlights;
final PenStroke? liveStroke;
final Size pageSize;
final double thinning;
@override
void paint(Canvas canvas, Size size) {
// 1. Text highlights (semi-transparent yellow), normalized → pixels.
if (highlights.isNotEmpty) {
final hp = Paint()
..color = const Color(0x66FFEB3B)
..style = PaintingStyle.fill;
for (final n in highlights) {
canvas.drawRect(
Rect.fromLTRB(
n.left * size.width,
n.top * size.height,
n.right * size.width,
n.bottom * size.height,
),
hp,
);
}
}
// 2. Committed ink.
for (final stroke in strokes) {
final path =
buildStrokePath(stroke, size, isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue;
canvas.drawPath(
path,
Paint()
..color = Color(stroke.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
}
// 3. Live stroke.
final live = liveStroke;
if (live != null && live.points.isNotEmpty) {
final path =
buildStrokePath(live, size, isComplete: false, thinning: thinning);
if (!path.getBounds().isEmpty) {
canvas.drawPath(
path,
Paint()
..color = Color(live.color)
..style = PaintingStyle.fill
..isAntiAlias = true,
);
}
}
}
@override
bool shouldRepaint(_PageOverlayPainter old) =>
!identical(old.strokes, strokes) ||
old.strokes.length != strokes.length ||
!identical(old.highlights, highlights) ||
old.highlights.length != highlights.length ||
!identical(old.liveStroke, liveStroke) ||
old.pageSize != pageSize ||
old.thinning != thinning;
}