Files
BadNote/lib/editor/canvas/pen_editor_screen.dart
Akiba So 2b1c6ba7e0
All checks were successful
CI / Windows build (push) Successful in 8m42s
feat: OneNote-style notebooks, text fonts, and page navigation
Add notebook.json containers with multi-member pages, fix PDF text
editing (size/bold/drag/double-tap), index SidecarText in search, and
share keyboard page shortcuts plus a PDF scrubber.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:27:35 +08:00

2792 lines
103 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
//
// 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 (Phase 2): strokes, highlights and scratch-links all write to a
// per-file SIDECAR (`<pdfPath>.badnote.json`) via SidecarRepository — debounced,
// atomic (temp+rename+.bak). The source file PATH is the identity (the sidecar
// IS the identity; the old djb2 path-hash document id is gone). Highlights now
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
import 'package:flutter/foundation.dart'
show ValueListenable, visibleForTesting;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:uuid/uuid.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bookmark.dart';
import '../../models/scratch_link.dart';
import '../../storage/badnote_sidecar.dart';
import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily;
import '../engine/brush.dart';
import '../engine/shape_geometry.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/sidecar_repository.dart';
import '../ui/page_nav_shortcuts.dart';
import '../ui/pen_settings_page.dart';
import '../ui/thumbnail_grid.dart';
import 'editor_tool.dart';
import 'ink_painters.dart' show buildStrokePath, paintForStroke;
import 'input_diagnostics.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
import 'pinch_scale_solver.dart';
import 'sticky_note_overlay.dart';
/// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space
/// (not scaled with zoom) so the tap target stays comfortably tappable.
const double _kMarkerSize = 36.0;
/// Default font size for a new text box, as a fraction of page WIDTH (so it
/// scales with zoom). ~3% of page width ≈ comfortable body text on a portrait
/// page.
const double _kDefaultTextFontFraction = 0.03;
/// Convert a CSS-like numeric weight (100900) to a [FontWeight], clamped to
/// the nearest of the 9 standard weights ([FontWeight.values] is w100..w900).
FontWeight _fontWeightFromValue(int weight) {
final idx = ((weight ~/ 100) - 1).clamp(0, FontWeight.values.length - 1);
return FontWeight.values[idx];
}
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;
/// Live 1-based value while the page pill's scrubber slider is being
/// dragged (like the slide editor's scrubber); null when not scrubbing.
double? _pageScrub;
/// 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. Persisted to the sidecar (Phase 2 — highlights now survive
/// reopen and can be removed via the un-highlight tool).
final Map<int, List<Rect>> _highlightsByPage = {};
/// Typed-text annotations per page, keyed by 0-based page index. Normalized
/// position + page-relative font size so they stay glued under zoom.
/// Persisted to the sidecar via [scheduleTextsSave]. PDF editor only for now
/// (note text is a later increment).
final Map<int, List<SidecarText>> _textsByPage = {};
/// The text box currently being edited (page + id), or null. While set a real
/// Flutter [TextField] is rendered over the box at its normalized position —
/// on Windows this receives IME + the Windows-Ink handwriting panel.
({int page, String id})? _editingText;
/// 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 ────────────────────────────────────────────────────────────
/// Per-file sidecar persistence. Identity is the source file PATH (the
/// sidecar IS the identity — no more djb2 path-hash document id). Null until
/// [_initPersistence] resolves.
SidecarRepository? _repo;
/// 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++;
// ── Glitch-guarded pinch zoom (we own scale; pdfrx owns scroll) ─────────────
// pdfrx's scaleEnabled is off, so we drive 2-finger zoom through the public
// controller (zoomOnLocalPosition) with the SAME guards as the note canvas's
// PenInteractiveViewer: per-frame scale-ratio clamp, pointer-count re-baseline
// to the last APPLIED scale, and a focal-jump guard. Tracking is absolute from
// a gesture-start snapshot via absolutePinchScale() — never a live read-back.
static const double _kPinchMinScale = 0.5;
static const double _kPinchMaxScale = 8.0;
/// A real pinch changes scale modestly per frame; a frame demanding far more
/// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
/// Logs showed ~1.30 spikes — keep the band below that.
static const double _kScaleGlitchHi = 1.18;
static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
static const double _kFocalGlitchPx = 100.0;
/// Matrix scale captured at the current baseline (gesture start or the last
/// pointer-count re-baseline). Null when no pinch is active.
double? _pinchScaleStart;
/// Pointer count of the previous accepted pinch frame; a change re-baselines.
int _pinchPointerCount = 0;
/// The recognizer's cumulative `details.scale` on the previous accepted frame.
double _pinchLastRawScale = 1.0;
/// The absolute scale we last APPLIED. Re-baseline anchors to THIS (not a live
/// matrix read) so the displayed scale stays continuous across a finger blip.
double _pinchLastAppliedScale = 1.0;
/// The recognizer's cumulative `details.scale` at the current baseline; the
/// absolute target normalizes against it (see [absolutePinchScale]).
double _pinchRawScaleAtBaseline = 1.0;
// ── 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 = [];
/// CURRENT live stroke, published to the page overlay painters. The painter
/// reads this at PAINT time (not as a build-time snapshot), so a mid-stroke
/// update repaints immediately — the ink follows the pen instead of only
/// appearing on pointer-up. Null when idle.
///
/// (The previous design passed a snapshot into the painter's constructor from
/// pageOverlaysBuilder, which only re-runs on setState; _bumpOverlay repainted
/// the painter but it still read the stale build-time snapshot → invisible ink
/// until commit. See `_LiveStrokeData` / `_PageOverlayPainter`.)
final ValueNotifier<_LiveStrokeData?> _liveStrokeVN = ValueNotifier(null);
/// 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. The single shared active-tool enum; the page-anchored
// select-text / place-link tools (below) are PDF-only and ride a different
// path (they disable pen capture), so they stay as their own booleans.
EditorToolKind _tool = EditorToolKind.brush;
/// Selected brush for the BRUSH 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;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// SELECT tool: ([page], strokeIndex) of the selected committed stroke, or
/// null. Selection is per-page (each page has its own stroke list).
({int page, int index})? _selected;
/// SHAPE tool: normalized start point + its page, while a shape drag is live.
({int page, Offset start})? _shapeDrag;
/// SELECT tool: last normalized drag position + page, to compute the
/// incremental translation; and whether the drag's undo snapshot was taken.
Offset? _selectLast;
bool _selectDragging = false;
/// rnote-style per-brush color memory: each brush (and the highlighter)
/// remembers its own color. Selecting a brush restores its color; picking a
/// color updates ONLY the active brush's entry. In-memory only for this
/// increment (TODO(brush-color-persist)).
final Map<BrushKind, Color> _brushColors = {
BrushKind.fountainPen: Colors.black,
BrushKind.ballpoint: Colors.blue,
BrushKind.pencil: Colors.green,
BrushKind.highlighter: Colors.orange,
};
/// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter,
/// else the selected pen brush).
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
/// When true the "select text" tool button is latched on.
bool _selectTextTool = false;
/// Barrel-held temporary select-text (OneNote-style); ORed into [_selectTextMode].
bool _barrelSelectText = false;
/// Select-text is active from the tool button OR a held barrel mapped to
/// [PenButtonAction.selectText].
bool get _selectTextMode => _selectTextTool || _barrelSelectText;
/// Expanded paper-sticky overlay for a scratch link (null = collapsed).
ScratchLink? _expandedSticky;
/// When true the "place scratch link" tool is active: a tap on a page drops a
/// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled
/// so the tap is handled by the per-page GestureDetector overlay.
bool _placeLinkMode = false;
/// When true the "remove highlight" tool is active: a tap on an existing
/// highlight rect deletes it (the un-highlight action). Pen capture is
/// disabled so the tap is handled by the per-page GestureDetector overlay.
bool _removeHighlightMode = false;
/// When true the TEXT tool is active: a pen-tap (the pen falls through to the
/// per-page overlay GestureDetector) OR a mouse double-click on a page drops a
/// new text box and focuses it. Pen capture is disabled so the overlay sees
/// the tap instead of the ink path.
bool _textMode = false;
/// All scratch-link anchors for this document, loaded on open and updated on
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
final List<ScratchLink> _scratchLinks = [];
/// All bookmarks for this document, loaded on open and updated on add/delete.
/// Listed in the bookmarks panel; tapping one jumps to its anchor. Scoped to
/// the PDF editor for now (note bookmarks are a later increment).
final List<Bookmark> _bookmarks = [];
static const _uuid = Uuid();
/// The active drawing color = the active brush's remembered color.
Color get _color => _brushColors[_activeColorBrush] ?? 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 = kInkPalette;
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
/// selection), place-link / sticky expanded, and text mode.
bool get _penCaptureEnabled =>
!_selectTextMode &&
!_placeLinkMode &&
!_removeHighlightMode &&
!_textMode &&
_expandedSticky == null;
/// True when the eraser tool is active.
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
/// True when the SELECT tool is active (and not in a page-anchored mode).
bool get _isSelect =>
_tool == EditorToolKind.select && _penCaptureEnabled;
/// True when the SHAPE tool is active.
bool get _isShape => _tool == EditorToolKind.shape && _penCaptureEnabled;
@override
void initState() {
super.initState();
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
// No-op on platforms without the plugin (W3).
PenInputService.instance.start();
PenInputService.instance.addListener(_onHwPenChanged);
_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(() {});
_syncBarrelSelectText();
}
void _onHwPenChanged() {
_syncBarrelSelectText();
}
/// Level-trigger: holding barrel with sideButton=selectText enables text
/// selection without latching the toolbar tool.
void _syncBarrelSelectText() {
final cfg = _penConfig?.value;
final hw = PenInputService.instance;
final want = cfg != null &&
hw.isActive &&
hw.current.barrel &&
cfg.sideButton == PenButtonAction.selectText;
if (want == _barrelSelectText) return;
if (!mounted) return;
setState(() {
_barrelSelectText = want;
if (want) {
_placeLinkMode = false;
_removeHighlightMode = false;
_textMode = false;
_selected = null;
_expandedSticky = null;
}
});
}
Future<void> _initPersistence() async {
final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
if (!mounted) {
repo.dispose();
return;
}
_repo = repo;
_loadFromSidecar(repo);
}
/// Hydrate the in-memory editor state from the sidecar [repo] loaded on open:
/// per-page strokes, per-page highlights, and scratch-link anchors.
void _loadFromSidecar(SidecarRepository repo) {
final loadedStrokes = <int, List<PenStroke>>{};
for (final entry in repo.loadedStrokes.entries) {
loadedStrokes[entry.key] = 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 is now persisted on the EditorStroke; carry it through
// so a reopened ballpoint/pencil/highlighter keeps its
// opacity/blend. Old sidecars without the field decode to
// fountainPen (see EditorStroke.brush back-compat default).
brush: es.brush,
))
.toList();
}
final loadedHighlights = <int, List<Rect>>{};
for (final entry in repo.loadedHighlights.entries) {
loadedHighlights[entry.key] =
entry.value.map((h) => h.toRect()).toList();
}
setState(() {
for (final entry in loadedStrokes.entries) {
_strokesByPage[entry.key] = entry.value;
}
for (final entry in loadedHighlights.entries) {
_highlightsByPage[entry.key] = entry.value;
}
for (final entry in repo.loadedTexts.entries) {
_textsByPage[entry.key] = List<SidecarText>.of(entry.value);
}
_scratchLinks
..clear()
..addAll(repo.loadedScratchLinks.map((s) => s.link));
_bookmarks
..clear()
..addAll(repo.loadedBookmarks);
});
_bumpOverlay();
}
@override
void dispose() {
// Flush any pending sidecar write before tearing down.
final repo = _repo;
if (repo != null) {
repo.flush(); // fire-and-forget; atomic write completes off the UI tree
repo.dispose();
}
_overlayRepaint.dispose();
_liveStrokeVN.dispose();
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
PenInputService.instance.removeListener(_onHwPenChanged);
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 sidecar repo.
void _schedulePageSave(int pageIndex, List<PenStroke> strokes) {
final repo = _repo;
if (repo == null) return;
final editorStrokes = strokes
.map((s) => simplifyStroke(EditorStroke.fromPenStroke(s)))
.toList();
repo.scheduleStrokeSave(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 hw = PenInputService.instance;
if (hw.isActive && hw.current.pressureValid) {
return hw.current.pressure.clamp(0.0, 1.0);
}
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;
}
/// Hit-test scratch-link markers near [normalized] on [page].
ScratchLink? _hitScratchMarker(int page, Offset normalized) {
const r = 0.045;
final r2 = r * r;
for (final link in _scratchLinks) {
if (link.pageIndex != page) continue;
final dx = link.nx - normalized.dx;
final dy = link.ny - normalized.dy;
if (dx * dx + dy * dy <= r2) return link;
}
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;
// Stylus can open sticky markers (PenCaptureRegion otherwise steals taps).
final sticky = _hitScratchMarker(hit.page, hit.normalized);
if (sticky != null) {
_openScratchLink(sticky);
return;
}
if (_isEraser) {
_liveStrokePage = hit.page;
_eraseAt(hit.page, hit.normalized);
return;
}
if (_isSelect) {
_liveStrokePage = hit.page;
_selectLast = hit.normalized;
_selectDragging = false;
_selectAt(hit.page, hit.normalized);
return;
}
if (_isShape) {
_liveStrokePage = hit.page;
_shapeDrag = (page: hit.page, start: 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;
}
if (_isSelect) {
if (hit == null || hit.page != page) return;
_dragSelected(page, hit.normalized);
return;
}
if (_isShape) {
if (hit == null || hit.page != page) return;
_updateShapePreview(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);
}
}
/// SELECT down: hit-test the page's committed strokes (topmost first) and set
/// the selection (or clear it on empty space).
void _selectAt(int page, Offset normalized) {
final strokes = _strokesByPage[page];
final radius = _penConfig?.value.eraserRadius ?? kDefaultEraserRadius;
final aspect = _pageAspect(page);
int? hitIndex;
if (strokes != null) {
for (var i = strokes.length - 1; i >= 0; i--) {
if (strokeHit(strokes[i], normalized.dx, normalized.dy, radius,
aspect: aspect)) {
hitIndex = i;
break;
}
}
}
setState(() {
_selected = hitIndex == null ? null : (page: page, index: hitIndex);
});
_bumpOverlay();
}
/// SELECT drag: translate the selected stroke by the incremental delta,
/// recording ONE undo snapshot on the first delta of the drag.
void _dragSelected(int page, Offset normalized) {
final sel = _selected;
final last = _selectLast;
if (sel == null || last == null || sel.page != page) {
_selectLast = normalized;
return;
}
final dx = normalized.dx - last.dx;
final dy = normalized.dy - last.dy;
_selectLast = normalized;
if (dx == 0 && dy == 0) return;
_moveSelected(dx, dy, isDragStart: !_selectDragging);
_selectDragging = true;
}
/// Translate the selected stroke by ([dx],[dy]); persists + (on [isDragStart])
/// records one undo snapshot via the existing per-page undo stack.
void _moveSelected(double dx, double dy, {required bool isDragStart}) {
final sel = _selected;
if (sel == null) return;
final list = _strokesByPage[sel.page];
if (list == null || sel.index < 0 || sel.index >= list.length) return;
if (isDragStart) _undoFor(sel.page).record(List<PenStroke>.of(list));
setState(() {
final next = List<PenStroke>.of(list);
next[sel.index] = translateStroke(next[sel.index], dx, dy);
_strokesByPage[sel.page] = next;
});
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
_bumpOverlay();
}
/// Delete the selected stroke (button or long-press) as one undo step.
void _deleteSelected() {
final sel = _selected;
if (sel == null) return;
final list = _strokesByPage[sel.page];
if (list == null || sel.index < 0 || sel.index >= list.length) return;
_undoFor(sel.page).record(List<PenStroke>.of(list));
setState(() {
final next = List<PenStroke>.of(list)..removeAt(sel.index);
_strokesByPage[sel.page] = next;
_selected = null;
});
_schedulePageSave(sel.page, List<PenStroke>.of(_strokesByPage[sel.page]!));
_bumpOverlay();
}
/// SHAPE preview: rebuild the generated shape stroke from start→current and
/// publish it as the live stroke (drawn by the page overlay painter).
void _updateShapePreview(int page, Offset current) {
final drag = _shapeDrag;
if (drag == null || drag.page != page) return;
final pts = generateShapePoints(
_shapeKind,
PenPoint(drag.start.dx, drag.start.dy, 1.0),
PenPoint(current.dx, current.dy, 1.0),
);
_liveStrokeVN.value = _LiveStrokeData(
page,
PenStroke(
points: pts,
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: PenStrokeKind.pen,
brush: kShapeBrush,
),
);
}
void _updateLiveStroke() {
final page = _liveStrokePage;
if (page == null || _livePoints.isEmpty) return;
final stroke = PenStroke(
points: List.of(_livePoints),
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: _currentKind(),
brush: _currentBrush(),
);
// Publish the live stroke so the page overlay painter repaints it NOW
// (notifies its repaint Listenable) — the ink follows the pen.
_liveStrokeVN.value = _LiveStrokeData(page, stroke);
}
void _endStroke({required bool commit}) {
final page = _liveStrokePage;
// SHAPE: on release, commit the generated shape stroke.
final drag = _shapeDrag;
if (_isShape && drag != null && commit) {
final end = _liveStrokeVN.value;
if (end != null && end.page == drag.page) {
_commitStroke(drag.page, end.stroke);
}
} else if (page != null &&
commit &&
!_isEraser &&
!_isSelect &&
!_isShape &&
_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;
_shapeDrag = null;
_selectLast = null;
_selectDragging = false;
_livePoints.clear();
_liveStrokeVN.value = 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 == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
PenStrokeKind _currentKind() => _tool == EditorToolKind.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 == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
Color _currentColor() => _tool == EditorToolKind.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}';
});
}
// ── Pinch zoom (glitch-guarded, drives pdfrx controller) ────────────────────
void _onPinchStart(ScaleStartDetails details) {
if (!_controller.isReady) return;
_pinchScaleStart = _controller.currentZoom;
_pinchPointerCount = details.pointerCount;
_pinchLastRawScale = 1.0;
_pinchLastAppliedScale = _pinchScaleStart!;
_pinchRawScaleAtBaseline = 1.0;
}
void _onPinchUpdate(ScaleUpdateDetails details) {
final scaleStart = _pinchScaleStart;
if (scaleStart == null || !_controller.isReady) return;
// Re-baseline on any pointer-count change (a finger lands/lifts, or a
// Windows touch 2↔1↔2 dropout). Anchor to the CLEAN tracked scale, not a
// matrix read-back, so the displayed scale is continuous; skip this frame.
if (details.pointerCount != _pinchPointerCount) {
_pinchPointerCount = details.pointerCount;
_pinchScaleStart = _pinchLastAppliedScale;
_pinchLastRawScale = details.scale;
_pinchRawScaleAtBaseline = details.scale;
return;
}
// Per-frame finger-motion ratio from the recognizer's OWN cumulative scale.
// A ratio outside the glitch band is a multi-touch spike → drop the frame;
// absolute tracking means the next good frame resumes from the true span.
final rawRatio =
_pinchLastRawScale > 0 ? details.scale / _pinchLastRawScale : 1.0;
final scaleDrop =
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
if (scaleDrop || focalDrop) {
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: _pinchLastAppliedScale,
appliedChange: 1.0,
focalJumpPx: details.focalPointDelta.distance,
scaleDrop: scaleDrop,
focalDrop: focalDrop,
);
return;
}
final targetScale = absolutePinchScale(
scaleStart: _pinchScaleStart!,
rawScaleAtBaseline: _pinchRawScaleAtBaseline,
rawScale: details.scale,
minScale: _kPinchMinScale,
maxScale: _kPinchMaxScale,
);
// Focal zoom: keep the document point under the live focal (finger midpoint)
// fixed, which also yields 2-finger pan for free as the focal moves.
// localFocalPoint is in the viewer's local coords (the overlay fills it).
_controller.zoomOnLocalPosition(
localPosition: details.localFocalPoint,
newZoom: targetScale,
duration: Duration.zero,
);
final applied =
_pinchLastAppliedScale > 0 ? targetScale / _pinchLastAppliedScale : 1.0;
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: targetScale,
appliedChange: applied,
focalJumpPx: details.focalPointDelta.distance,
scaleDrop: false,
focalDrop: false,
);
_pinchLastRawScale = details.scale;
_pinchLastAppliedScale = targetScale;
}
void _onPinchEnd(ScaleEndDetails details) {
_pinchScaleStart = null;
_pinchPointerCount = 0;
}
// ── 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;
});
// Persist each touched page's highlights to the sidecar (Phase 2 — closes
// the old in-memory-only limitation; highlights now survive reopen).
for (final pageIndex in added.keys) {
_scheduleHighlightSave(pageIndex);
}
await delegate.clearTextSelection();
_bumpOverlay();
}
/// Serialize the current highlights for [pageIndex] to the sidecar.
void _scheduleHighlightSave(int pageIndex) {
final repo = _repo;
if (repo == null) return;
final rects = _highlightsByPage[pageIndex] ?? const <Rect>[];
repo.scheduleHighlightSave(
pageIndex,
rects.map((r) => SidecarHighlight.fromRect(r)).toList(),
);
}
/// Remove the highlight rect on [pageIndex] under the normalized point
/// [normalized] (topmost hit), persisting the change. Returns true if one was
/// removed. This is the "un-highlight" action (a stored highlight could not be
/// removed before): in SELECT-TEXT mode a tap on an existing highlight erases
/// it.
bool _removeHighlightAt(int pageIndex, Offset normalized) {
final rects = _highlightsByPage[pageIndex];
if (rects == null || rects.isEmpty) return false;
for (var i = rects.length - 1; i >= 0; i--) {
if (rects[i].contains(normalized)) {
setState(() {
final next = List<Rect>.of(rects)..removeAt(i);
if (next.isEmpty) {
_highlightsByPage.remove(pageIndex);
} else {
_highlightsByPage[pageIndex] = next;
}
});
_scheduleHighlightSave(pageIndex);
_bumpOverlay();
return true;
}
}
return false;
}
// ── Typed text annotations (PDF editor only for now) ────────────────────────
/// Serialize the current text annotations for [pageIndex] to the sidecar.
void _scheduleTextsSave(int pageIndex) {
final repo = _repo;
if (repo == null) return;
repo.scheduleTextsSave(
pageIndex,
List<SidecarText>.of(_textsByPage[pageIndex] ?? const <SidecarText>[]),
);
}
/// Create a new text box at normalized [normalized] on [pageIndex] and focus
/// it for input. (Not undoable for this increment — see report; a blank box
/// self-deletes on blur, so a stray placement leaves no residue.)
void _placeTextBox(int pageIndex, Offset normalized) {
final id = _uuid.v4();
final box = SidecarText(
id: id,
nx: normalized.dx.clamp(0.0, 1.0),
ny: normalized.dy.clamp(0.0, 1.0),
text: '',
fontSize: _kDefaultTextFontFraction,
color: _color.toARGB32(),
fontFamily: kAnnotationFontFamily,
);
setState(() {
_textsByPage[pageIndex] = [...?_textsByPage[pageIndex], box];
_editingText = (page: pageIndex, id: id);
});
_bumpOverlay();
}
/// Open an existing text box [id] on [pageIndex] for editing.
void _editTextBox(int pageIndex, String id) {
setState(() => _editingText = (page: pageIndex, id: id));
}
/// Live edit: replace the editing box's text. Persisted (debounced) so the
/// content survives a crash mid-typing.
void _updateEditingText(String text) {
final editing = _editingText;
if (editing == null) return;
final list = _textsByPage[editing.page];
if (list == null) return;
final idx = list.indexWhere((t) => t.id == editing.id);
if (idx == -1) return;
setState(() {
final next = List<SidecarText>.of(list);
next[idx] = next[idx].copyWith(text: text);
_textsByPage[editing.page] = next;
});
_scheduleTextsSave(editing.page);
_bumpOverlay();
}
/// Finish editing (field blur / tool change): if the box is empty it is
/// removed (empty-on-blur deletes); otherwise the committed text is persisted.
void _finishTextEdit() {
final editing = _editingText;
if (editing == null) return;
final list = _textsByPage[editing.page];
if (list != null) {
final idx = list.indexWhere((t) => t.id == editing.id);
if (idx != -1 && list[idx].text.trim().isEmpty) {
setState(() {
final next = List<SidecarText>.of(list)..removeAt(idx);
if (next.isEmpty) {
_textsByPage.remove(editing.page);
} else {
_textsByPage[editing.page] = next;
}
});
_scheduleTextsSave(editing.page);
}
}
setState(() => _editingText = null);
_bumpOverlay();
}
/// TEXT tool drag-to-move: nudge box [id] on [pageIndex] by [deltaPx] (a
/// screen-pixel pan delta), converting to a normalized delta via the page's
/// on-screen [pageW]/[pageH]. Persists (debounced) like other text edits.
void _dragTextBox(
int pageIndex,
String id,
Offset deltaPx,
double pageW,
double pageH,
) {
if (pageW <= 0 || pageH <= 0) return;
final list = _textsByPage[pageIndex];
if (list == null) return;
final idx = list.indexWhere((t) => t.id == id);
if (idx == -1) return;
final t = list[idx];
final nx = (t.nx + deltaPx.dx / pageW).clamp(0.0, 1.0);
final ny = (t.ny + deltaPx.dy / pageH).clamp(0.0, 1.0);
setState(() {
final next = List<SidecarText>.of(list);
next[idx] = t.copyWith(nx: nx, ny: ny);
_textsByPage[pageIndex] = next;
});
_scheduleTextsSave(pageIndex);
_bumpOverlay();
}
/// Live style edit for the box currently being edited: updates its
/// page-relative [fontSize] fraction and/or numeric [fontWeight] via
/// copyWith, persisting (debounced) like [_updateEditingText].
void _updateEditingStyle({double? fontSize, int? fontWeight}) {
final editing = _editingText;
if (editing == null) return;
final list = _textsByPage[editing.page];
if (list == null) return;
final idx = list.indexWhere((t) => t.id == editing.id);
if (idx == -1) return;
setState(() {
final next = List<SidecarText>.of(list);
next[idx] = next[idx].copyWith(
fontSize: fontSize,
fontWeight: fontWeight,
);
_textsByPage[editing.page] = next;
});
_scheduleTextsSave(editing.page);
_bumpOverlay();
}
/// Toggle the TEXT tool (drops [_editingText] when leaving, so a half-typed
/// box gets the empty-on-blur treatment).
void _toggleTextMode() {
if (_textMode) {
_finishTextEdit();
setState(() => _textMode = false);
} else {
_setTool(EditorToolKind.text);
}
}
// ── Navigation / tools ─────────────────────────────────────────────────────
void _goToPage(int index) {
if (_pageCount == 0) return;
final clamped = index.clamp(0, _pageCount - 1);
_controller.goToPage(pageNumber: clamped + 1);
}
void _setTool(EditorToolKind tool) {
setState(() {
_tool = tool;
_selectTextTool = false;
_placeLinkMode = false;
_removeHighlightMode = false;
// The TEXT tool is the one EditorToolKind that drives a page-anchored
// (non-ink) interaction, so it owns the _textMode flag.
_textMode = tool == EditorToolKind.text;
if (tool != EditorToolKind.select) _selected = null;
});
}
void _enableSelectText() {
setState(() {
_selectTextTool = true;
_placeLinkMode = false;
_removeHighlightMode = false;
_textMode = false;
_selected = null;
_expandedSticky = null;
});
}
/// Toggle "place scratch link" mode. While active, a tap on a page drops a
/// new anchor at the tapped normalized position.
void _togglePlaceLinkMode() {
setState(() {
_placeLinkMode = !_placeLinkMode;
if (_placeLinkMode) {
_selectTextTool = false;
_removeHighlightMode = false;
_textMode = false;
_expandedSticky = null;
}
});
}
/// Toggle "remove highlight" mode (the un-highlight action). While active, a
/// tap on an existing highlight deletes it (persisted to the sidecar).
void _toggleRemoveHighlightMode() {
setState(() {
_removeHighlightMode = !_removeHighlightMode;
if (_removeHighlightMode) {
_selectTextTool = false;
_placeLinkMode = false;
_textMode = false;
_selected = null;
_expandedSticky = null;
}
});
}
// ── Scratch-link anchors ─────────────────────────────────────────────────────
/// Create + persist a new anchor at normalized [normalized] on [pageIndex],
/// then show it. Leaves place-link mode on so several can be dropped in a row.
Future<void> _placeScratchLink(int pageIndex, Offset normalized) async {
final link = ScratchLink(
id: _uuid.v4(),
// The source file path is the identity now (the sidecar IS the identity);
// documentId is retained on the model only so the split view can address
// the source PDF. Use the file path so it's stable across reopen.
documentId: widget.pdfPath,
pageIndex: pageIndex,
nx: normalized.dx.clamp(0.0, 1.0),
ny: normalized.dy.clamp(0.0, 1.0),
);
_repo?.scheduleScratchLinkUpsert(link);
if (!mounted) return;
setState(() => _scratchLinks.add(link));
}
/// Expand the sticky overlay on-page (paper sticky UX). Uses the SAME
/// [SidecarRepository] — no second open, no split-view race.
Future<void> _openScratchLink(ScratchLink link) async {
setState(() {
_expandedSticky = link;
_selectTextTool = false;
_placeLinkMode = false;
_removeHighlightMode = false;
_textMode = false;
});
}
void _closeSticky() {
if (!mounted) return;
setState(() => _expandedSticky = null);
}
/// Confirm + delete an anchor (and its private scratchpad).
Future<void> _confirmDeleteScratchLink(ScratchLink link) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.scratchLinkDeleteTitle),
content: Text(l.scratchLinkDeleteBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.delete),
),
],
),
);
if (confirmed != true) return;
_repo?.scheduleScratchLinkDelete(link.id);
if (!mounted) return;
setState(() => _scratchLinks.removeWhere((s) => s.id == link.id));
}
// ── Bookmarks (paragraph-precise) ────────────────────────────────────────────
/// Add a bookmark at a PRECISE location. Prefers the current text selection's
/// START fragment (page + normalized rect + char index = the paragraph) so the
/// bookmark lands on the exact paragraph; falls back to the current page's top
/// when there is no selection. The label is the selected-text snippet
/// (truncated) or "Page N".
Future<void> _addBookmark() async {
if (!_controller.isReady) return;
final l = AppLocalizations.of(context);
int pageNumber = _pageIndex + 1; // 1-based
double? aLeft, aTop, aRight, aBottom;
int? charIndex;
String label = '';
if (_hasSelection) {
final delegate = _controller.textSelectionDelegate;
final ranges = await delegate.getSelectedTextRanges();
if (!mounted) return;
if (ranges.isNotEmpty) {
final range = ranges.first;
final doc = _controller.document;
final pageIndex = range.pageNumber - 1;
if (pageIndex >= 0 && pageIndex < doc.pages.length) {
final page = doc.pages[pageIndex];
final w = page.width;
final h = page.height;
if (w > 0 && h > 0) {
// First fragment's bounding rect → normalized page rect (top-left
// origin), exactly as _highlightSelection normalizes highlight
// rects.
for (final frag in range.enumerateFragmentBoundingRects()) {
final r = frag.bounds.toRect(page: page);
aLeft = (r.left / w).clamp(0.0, 1.0);
aTop = (r.top / h).clamp(0.0, 1.0);
aRight = (r.right / w).clamp(0.0, 1.0);
aBottom = (r.bottom / h).clamp(0.0, 1.0);
break; // anchor to the FIRST fragment (the selection start).
}
}
}
pageNumber = range.pageNumber;
charIndex = range.start;
final text = range.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (text.isNotEmpty) {
label = text.length > 60 ? '${text.substring(0, 60)}' : text;
}
await delegate.clearTextSelection();
if (!mounted) return;
setState(() => _hasSelection = false);
}
}
if (label.isEmpty) label = l.bookmarkDefaultLabel(pageNumber);
final bookmark = Bookmark(
id: _uuid.v4(),
// The source file path is the identity (the sidecar IS the identity).
documentId: widget.pdfPath,
pageNumber: pageNumber,
label: label,
createdAt: DateTime.now().toUtc(),
anchorLeft: aLeft,
anchorTop: aTop,
anchorRight: aRight,
anchorBottom: aBottom,
charIndex: charIndex,
);
_repo?.scheduleBookmarkUpsert(bookmark);
if (!mounted) return;
setState(() => _bookmarks.add(bookmark));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(bookmark.label),
duration: const Duration(seconds: 2),
),
);
}
/// Jump to a bookmark: scroll to its page and, when it carries a normalized
/// in-page anchor rect, to that exact paragraph (via goToRectInsidePage).
/// PDF page coords have a BOTTOM-left origin (Y up), so the stored top-left
/// normalized rect is flipped on Y when reconstructing the PdfRect.
Future<void> _goToBookmark(Bookmark bookmark) async {
if (!_controller.isReady) return;
final pageNumber = bookmark.pageNumber.clamp(1, _pageCount);
final top = bookmark.anchorTop;
final left = bookmark.anchorLeft;
if (top == null || left == null) {
_controller.goToPage(pageNumber: pageNumber);
return;
}
final page = _controller.document.pages[pageNumber - 1];
final w = page.width;
final h = page.height;
final right = bookmark.anchorRight ?? left;
final bottom = bookmark.anchorBottom ?? top;
// Flutter (y-down) normalized → PDF (y-up) page coords.
final pdfRect = PdfRect(
(left * w).clamp(0.0, w),
((1.0 - top) * h).clamp(0.0, h), // pdf top (bigger)
(right * w).clamp(0.0, w),
((1.0 - bottom) * h).clamp(0.0, h), // pdf bottom (smaller)
);
await _controller.goToRectInsidePage(
pageNumber: pageNumber,
rect: pdfRect,
anchor: PdfPageAnchor.top,
);
}
/// Confirm + delete a bookmark (persisted).
Future<void> _confirmDeleteBookmark(Bookmark bookmark) async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.bookmarkDeleteTitle),
content: Text(l.bookmarkDeleteBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.delete),
),
],
),
);
if (confirmed != true) return;
_repo?.scheduleBookmarkDelete(bookmark.id);
if (!mounted) return;
setState(() => _bookmarks.removeWhere((b) => b.id == bookmark.id));
}
/// Open the bookmarks panel (a bottom sheet): each entry shows its label +
/// page; tap → jump to the anchor; swipe to dismiss → delete (persisted).
void _openBookmarksPanel() {
final l = AppLocalizations.of(context);
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
l.bookmarksTitle,
style: Theme.of(sheetContext).textTheme.titleMedium,
),
),
if (_bookmarks.isEmpty)
Padding(
padding: const EdgeInsets.all(24),
child: Text(
l.bookmarksEmpty,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(sheetContext).colorScheme.outline,
),
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _bookmarks.length,
itemBuilder: (context, i) {
final bm = _bookmarks[i];
return Dismissible(
key: ValueKey(bm.id),
direction: DismissDirection.endToStart,
background: Container(
color: Theme.of(context).colorScheme.errorContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 24),
child: Icon(
Icons.delete_outline,
color:
Theme.of(context).colorScheme.onErrorContainer,
),
),
onDismissed: (_) {
_repo?.scheduleBookmarkDelete(bm.id);
setState(
() => _bookmarks.removeWhere((b) => b.id == bm.id),
);
},
child: ListTile(
leading: Icon(
Icons.bookmark,
color: Color(bm.color),
),
title: Text(
bm.label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(l.bookmarkPageLabel(bm.pageNumber)),
onTap: () {
Navigator.pop(sheetContext);
_goToBookmark(bm);
},
onLongPress: () {
Navigator.pop(sheetContext);
_confirmDeleteBookmark(bm);
},
),
);
},
),
),
],
),
),
);
},
);
}
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);
final formatBar = _buildTextFormatBar();
return Scaffold(
body: pageNavShortcuts(
onPrevious:
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
onNext: _pageIndex < _pageCount - 1
? () => _goToPage(_pageIndex + 1)
: null,
onFirst: _pageCount > 0 ? () => _goToPage(0) : null,
onLast: _pageCount > 0 ? () => _goToPage(_pageCount - 1) : null,
child: 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(),
),
),
),
// Compact text-format bar (S/M/L + Bold), shown BELOW the tool
// palette while a text box is being edited.
if (formatBar != null)
SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 68),
child: formatBar,
),
),
),
// 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(
// pdfrx keeps 1-finger scroll + wheel (panEnabled) but we TAKE OVER the
// 2-finger pinch (scaleEnabled:false): pdfrx's forked InteractiveViewer
// applies `desiredScale = _scaleStart * details.scale` with no per-frame
// glitch guard, so a Windows-touch scale spike or pointer-count blip pops
// the zoom and snaps back. We drive zoom ourselves via the glitch-guarded
// _TwoFingerPinch recognizer in viewerOverlayBuilder → controller.
// zoomOnLocalPosition (focal zoom). See _onPinchUpdate.
panEnabled: true,
scaleEnabled: false,
// 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;
final pageW = pageRectInViewer.width;
final pageH = pageRectInViewer.height;
final linksOnPage =
_scratchLinks.where((l) => l.pageIndex == pageIndex);
return [
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _PageOverlayPainter(
overlayRepaint: _overlayRepaint,
liveStrokeVN: _liveStrokeVN,
pageIndex: pageIndex,
strokes: _strokesByPage[pageIndex] ?? const [],
highlights: _highlightsByPage[pageIndex] ?? const [],
selectedIndex:
(_selected != null && _selected!.page == pageIndex)
? _selected!.index
: null,
pageSize: pageRectInViewer.size,
thinning: _penConfig?.value.pressureSensitivity ??
kDefaultPenThinning,
),
),
),
),
// Tap-to-place layer: only swallows taps while place-link mode is on.
// Otherwise it's a no-op (IgnorePointer) so ink/scroll fall through.
if (_placeLinkMode)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
final local = details.localPosition;
if (pageW <= 0 || pageH <= 0) return;
final nx = (local.dx / pageW).clamp(0.0, 1.0);
final ny = (local.dy / pageH).clamp(0.0, 1.0);
_placeScratchLink(pageIndex, Offset(nx, ny));
},
),
),
// Tap-to-remove-highlight layer (the un-highlight action): only
// swallows taps while remove-highlight mode is on; a tap on an
// existing highlight deletes it (persisted to the sidecar).
if (_removeHighlightMode)
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
final local = details.localPosition;
if (pageW <= 0 || pageH <= 0) return;
final nx = (local.dx / pageW).clamp(0.0, 1.0);
final ny = (local.dy / pageH).clamp(0.0, 1.0);
_removeHighlightAt(pageIndex, Offset(nx, ny));
},
),
),
// Placement layer: while the TEXT tool is active, a pen-tap OR a
// mouse double-click on empty page space drops a new box. It sits
// BELOW the per-box labels in the stack so a tap that lands on an
// existing label edits it instead of placing a new box.
if (_textMode)
Positioned.fill(
child: _TextPlacementLayer(
onPlace: (local) {
if (pageW <= 0 || pageH <= 0) return;
final nx = (local.dx / pageW).clamp(0.0, 1.0);
final ny = (local.dy / pageH).clamp(0.0, 1.0);
_placeTextBox(pageIndex, Offset(nx, ny));
},
),
),
// Typed-text annotations (committed). Each non-editing box is a
// tappable label glued at (nx*pageW, ny*pageH) with page-scaled
// font. Tapping one re-opens it for editing. The box currently
// being edited is rendered as a TextField below instead.
for (final t in (_textsByPage[pageIndex] ?? const <SidecarText>[]))
if (!(_editingText?.page == pageIndex &&
_editingText?.id == t.id))
Positioned(
left: t.nx * pageW,
top: t.ny * pageH,
child: _TextAnnotationLabel(
text: t.text,
fontSizePx: t.fontSize * pageW,
color: Color(t.color),
fontWeight: _fontWeightFromValue(t.fontWeight),
fontFamily: t.fontFamily,
onTap: _textMode ? () => _editTextBox(pageIndex, t.id) : null,
// Double-tap always edits, even outside text mode.
onDoubleTap: () => _editTextBox(pageIndex, t.id),
onPanUpdate: _textMode
? (details) => _dragTextBox(
pageIndex,
t.id,
details.delta,
pageW,
pageH,
)
: null,
),
),
// Active editing field for a box on this page: a real Flutter
// TextField so the OS IME + Windows-Ink handwriting panel work.
if (_editingText?.page == pageIndex)
for (final t in (_textsByPage[pageIndex] ?? const <SidecarText>[]))
if (t.id == _editingText!.id)
Positioned(
left: t.nx * pageW,
top: t.ny * pageH,
width: (pageW - t.nx * pageW).clamp(40.0, pageW),
child: _TextAnnotationField(
key: ValueKey('text-edit-${t.id}'),
initialText: t.text,
fontSizePx: t.fontSize * pageW,
color: Color(t.color),
fontWeight: _fontWeightFromValue(t.fontWeight),
fontFamily: t.fontFamily,
hintText: AppLocalizations.of(context).textPlaceholder,
onChanged: _updateEditingText,
onDone: _finishTextEdit,
),
),
// Anchor markers (sticky-note tabs): tap → split view, long-press →
// delete. Sized in screen px so the tap target stays usable at any
// zoom; positioned at (nx*pageW, ny*pageH).
for (final link in linksOnPage)
Positioned(
left: link.nx * pageW - _kMarkerSize / 2,
top: link.ny * pageH - _kMarkerSize / 2,
width: _kMarkerSize,
height: _kMarkerSize,
child: _ScratchLinkMarker(
onTap: () => _openScratchLink(link),
onLongPress: () => _confirmDeleteScratchLink(link),
),
),
];
},
// (2) Viewer-level pen capture + our glitch-guarded pinch. Stylus is
// captured ONLY when a pen tool is active; touch/mouse (and pen in
// select-text mode) fall through to pdfrx for scroll/text-selection.
// The pinch recognizer is touch-only and concedes the arena below 2
// pointers, so 1-finger scroll still reaches pdfrx and the pen (captured
// outside the arena by PenCaptureRegion) is never stolen.
viewerOverlayBuilder: (context, size, handleLinkTap) {
return [
Positioned.fill(
child: RawGestureDetector(
// translucent (NOT opaque): the touch must ALSO hit-test pdfrx
// underneath so its pan recognizer can win the 1-finger case.
behavior: HitTestBehavior.translucent,
gestures: {
_TwoFingerPinch:
GestureRecognizerFactoryWithHandlers<_TwoFingerPinch>(
() => _TwoFingerPinch(debugOwner: this),
(r) => r
..onStart = _onPinchStart
..onUpdate = _onPinchUpdate
..onEnd = _onPinchEnd,
),
},
child: const SizedBox.expand(),
),
),
Positioned.fill(
child: PenCaptureRegion(
captureEnabled: _penCaptureEnabled,
onPenEvent: _onPenEvent,
child: const IgnorePointer(child: SizedBox.expand()),
),
),
if (_hasSelection)
Positioned(
left: 16,
right: 16,
bottom: 24,
child: _SelectionActionBar(
onHighlight: _highlightSelection,
onBookmark: _addBookmark,
),
),
if (_expandedSticky != null && _repo != null)
Positioned(
right: 20,
top: 72,
child: StickyNoteOverlay(
key: ValueKey(_expandedSticky!.id),
link: _expandedSticky!,
repo: _repo!,
onClose: _closeSticky,
onDelete: () async {
final link = _expandedSticky!;
_closeSticky();
await _confirmDeleteScratchLink(link);
},
),
),
];
},
),
);
}
/// 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 == EditorToolKind.brush && _penCaptureEnabled,
tooltip: l.brushPicker,
labelFor: (b) => brushLabel(b, l),
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) {
setState(() => _penBrush = b);
_setTool(EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
tooltip: l.toolHighlighter,
onPressed: () => _setTool(EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _isEraser,
tooltip: l.toolEraser,
onPressed: () => _setTool(EditorToolKind.eraser),
),
ToolButton(
icon: Icons.ads_click,
selected: _isSelect,
tooltip: l.toolSelect,
onPressed: () => _setTool(EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _isShape,
tooltip: l.shapePicker,
labelFor: (s) => shapeLabel(s, l),
onActivate: () => _setTool(EditorToolKind.shape),
onSelected: (s) {
setState(() => _shapeKind = s);
_setTool(EditorToolKind.shape);
},
),
if (_isSelect && _selected != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: l.actionDeleteSelection,
onPressed: _deleteSelected,
),
// Typed-text tool: pen-tap or mouse double-click drops a text box.
ToolButton(
icon: Icons.title,
selected: _textMode,
tooltip: l.toolText,
onPressed: _toggleTextMode,
),
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,
),
// Un-highlight: toggle a mode where tapping an existing highlight
// removes it (the user couldn't remove highlights before).
ToolButton(
icon: Icons.highlight_off,
selected: _removeHighlightMode,
tooltip: l.toolRemoveHighlight,
onPressed: _toggleRemoveHighlightMode,
),
PaletteDivider(cs: cs),
// Place scratch link (sticky-note tab). Tap a page to drop an anchor.
ToolButton(
icon: Icons.sticky_note_2_outlined,
selected: _placeLinkMode,
tooltip: l.toolPlaceScratchLink,
onPressed: _togglePlaceLinkMode,
),
PaletteDivider(cs: cs),
// Bookmark: add (selection-anchored if any, else current page) +
// open the bookmarks panel (list / jump-to / delete).
ToolButton(
icon: Icons.bookmark_add_outlined,
selected: false,
tooltip: l.toolAddBookmark,
onPressed: _viewerReady ? _addBookmark : null,
),
ToolButton(
icon: Icons.bookmarks_outlined,
selected: false,
tooltip: l.toolBookmarks,
onPressed: _viewerReady ? _openBookmarksPanel : 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();
}
},
),
],
),
),
);
}
/// Compact text-format bar shown (below the tool palette) while a text box
/// is being edited: S/M/L font-size presets + a Bold toggle. Edits apply
/// live to the editing [SidecarText] via [_updateEditingStyle]. Returns null
/// when nothing is being edited (or the box has since been removed).
Widget? _buildTextFormatBar() {
final editing = _editingText;
if (editing == null) return null;
final list = _textsByPage[editing.page];
if (list == null) return null;
final idx = list.indexWhere((t) => t.id == editing.id);
if (idx == -1) return null;
final current = list[idx];
final cs = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
final isBold = current.fontWeight >= 700;
Widget sizeButton(String label, double fraction) {
final selected = (current.fontSize - fraction).abs() < 0.001;
return TextButton(
style: TextButton.styleFrom(
minimumSize: const Size(36, 36),
padding: EdgeInsets.zero,
backgroundColor:
selected ? cs.secondaryContainer : Colors.transparent,
foregroundColor:
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant,
),
onPressed: () => _updateEditingStyle(fontSize: fraction),
child: Text(label),
);
}
return Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
sizeButton(l.textFontSmall, 0.022),
sizeButton(l.textFontMedium, 0.03),
sizeButton(l.textFontLarge, 0.045),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.format_bold,
selected: isBold,
tooltip: l.textBold,
onPressed: () =>
_updateEditingStyle(fontWeight: isBold ? 400 : 700),
),
],
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
// Selected against the ACTIVE brush's remembered color; a tap updates only
// that brush's entry (rnote per-brush color memory). Inert in select mode.
final selected = _color == c && !_isSelect;
return GestureDetector(
onTap: () => setState(() => _brushColors[_activeColorBrush] = 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 scrubber Slider (multi-page docs) above a
/// COMPACT pill (prev / "n / total" / next). Mirrors the slide editor's
/// scrubber; the center button now opens the thumbnail grid.
Widget _buildPagePill() {
final cs = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
final total = _pageCount;
final scrub = _pageScrub;
final shown = (scrub ?? (_pageIndex + 1).toDouble()).round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (total > 1)
Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(maxWidth: 420),
child: Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Slider(
min: 1,
max: total.toDouble(),
value: (scrub ?? (_pageIndex + 1).toDouble())
.clamp(1, total.toDouble()),
divisions: total > 1 ? total - 1 : null,
onChanged: (v) => setState(() => _pageScrub = v),
onChangeEnd: (v) {
setState(() => _pageScrub = null);
_goToPage(v.round() - 1);
},
),
),
),
),
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: _viewerReady ? _openThumbnails : 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)),
),
),
],
);
},
),
),
),
),
),
),
);
}
}
/// A [ScaleGestureRecognizer] that competes ONLY for 2-finger touch gestures.
///
/// pdfrx's internal pan recognizer runs underneath (scaleEnabled is off, so it
/// still scrolls on 1 finger). A vanilla ScaleGestureRecognizer is eager: with a
/// single uncontested pointer it would win the arena and STEAL 1-finger scroll.
/// Restricting [supportedDevices] to touch keeps the stylus out (the pen is
/// captured outside the arena by [PenCaptureRegion]); rejecting the pointer while
/// fewer than 2 are down hands the 1-finger gesture back to pdfrx immediately,
/// and accepting on the 2nd finger lets us drive the pinch. Guarding inside
/// onUpdate would be too late — by then the arena is already won.
class _TwoFingerPinch extends ScaleGestureRecognizer {
_TwoFingerPinch({super.debugOwner})
: super(supportedDevices: const {PointerDeviceKind.touch});
final Set<int> _pointers = <int>{};
@override
void addAllowedPointer(PointerDownEvent event) {
_pointers.add(event.pointer);
super.addAllowedPointer(event);
if (_pointers.length < 2) {
// Concede the 1-finger case now so pdfrx's pan wins without waiting.
resolvePointer(event.pointer, GestureDisposition.rejected);
} else {
// Second finger down: claim the gesture before pdfrx treats it as a pan.
resolve(GestureDisposition.accepted);
}
}
@override
void handleEvent(PointerEvent event) {
if (event is PointerUpEvent || event is PointerCancelEvent) {
_pointers.remove(event.pointer);
}
super.handleEvent(event);
}
@override
void rejectGesture(int pointer) {
_pointers.remove(pointer);
super.rejectGesture(pointer);
}
}
/// The currently in-progress stroke and the page it belongs to. Published via a
/// [ValueNotifier] so [_PageOverlayPainter] can read the live stroke at PAINT
/// time (driven by the notifier) instead of capturing a stale build-time
/// snapshot — that snapshot bug made ink appear only on pointer-up.
class _LiveStrokeData {
const _LiveStrokeData(this.page, this.stroke);
final int page;
final PenStroke stroke;
}
/// 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.
///
/// The live stroke is read from [liveStrokeVN] at paint time (not passed as a
/// constructor snapshot) so that mid-stroke updates — which notify the merged
/// repaint Listenable — redraw the in-progress ink immediately. Committed
/// strokes + highlights still arrive via the constructor (they change only on
/// setState, which rebuilds this painter).
class _PageOverlayPainter extends CustomPainter {
_PageOverlayPainter({
required Listenable overlayRepaint,
required this.liveStrokeVN,
required this.pageIndex,
required this.strokes,
required this.highlights,
required this.pageSize,
required this.thinning,
this.selectedIndex,
}) : super(repaint: Listenable.merge([overlayRepaint, liveStrokeVN]));
/// Live stroke source, read at paint time. Only painted when its page matches
/// [pageIndex].
final ValueListenable<_LiveStrokeData?> liveStrokeVN;
final int pageIndex;
final List<PenStroke> strokes;
final List<Rect> highlights;
final Size pageSize;
final double thinning;
/// SELECT tool: index into [strokes] of the selected stroke on this page, or
/// null. Drives the selection bounding-box overlay.
final int? selectedIndex;
@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;
// Single drawPath per stroke ⇒ highlighter self-overlap never darkens;
// cross-stroke overlap darkens via BlendMode.multiply (closes
// TODO(brush-opacity); shared resolver with the PenCanvas painters).
canvas.drawPath(path, paintForStroke(stroke));
}
// 3. Live stroke — read from the notifier at paint time, only for this page.
final live = liveStrokeVN.value;
if (live != null && live.page == pageIndex && live.stroke.points.isNotEmpty) {
final path = buildStrokePath(live.stroke, size,
isComplete: false, thinning: thinning);
if (!path.getBounds().isEmpty) {
canvas.drawPath(path, paintForStroke(live.stroke));
}
}
// 4. SELECT bounding box around the selected stroke (over everything).
final si = selectedIndex;
if (si != null && si >= 0 && si < strokes.length) {
final b = penStrokeBounds(strokes[si]);
if (b != null) {
const padPx = 6.0;
final rect = Rect.fromLTRB(
b.left * size.width - padPx,
b.top * size.height - padPx,
b.right * size.width + padPx,
b.bottom * size.height + padPx,
);
final rr = RRect.fromRectAndRadius(rect, const Radius.circular(4));
canvas.drawRRect(
rr,
Paint()
..color = const Color(0xFF2962FF).withValues(alpha: 0.12)
..style = PaintingStyle.fill,
);
canvas.drawRRect(
rr,
Paint()
..color = const Color(0xFF2962FF)
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..isAntiAlias = true,
);
}
}
}
@override
bool shouldRepaint(_PageOverlayPainter old) =>
!identical(old.liveStrokeVN, liveStrokeVN) ||
old.pageIndex != pageIndex ||
!identical(old.strokes, strokes) ||
old.strokes.length != strokes.length ||
!identical(old.highlights, highlights) ||
old.highlights.length != highlights.length ||
old.selectedIndex != selectedIndex ||
old.pageSize != pageSize ||
old.thinning != thinning;
}
/// Test-only handle on the live-stroke painter wiring (Bug 1 regression guard).
///
/// The live stroke must be read from a [ValueListenable] at PAINT time, so a
/// mid-stroke update repaints WITHOUT a rebuild of `pageOverlaysBuilder`. This
/// seam lets a widget test pump the real painter and assert that pushing a new
/// value into the notifier triggers a repaint (which the old build-time-snapshot
/// design did not).
@visibleForTesting
class LiveStrokeOverlayHarness {
LiveStrokeOverlayHarness({required this.pageIndex});
final int pageIndex;
final ValueNotifier<int> overlayRepaint = ValueNotifier<int>(0);
final ValueNotifier<_LiveStrokeData?> _liveStrokeVN = ValueNotifier(null);
/// Push a live stroke for [page] (or null to clear). Mirrors what
/// `_updateLiveStroke`/`_endStroke` do at runtime.
void setLiveStroke(int page, PenStroke? stroke) {
_liveStrokeVN.value = stroke == null ? null : _LiveStrokeData(page, stroke);
}
/// The real [CustomPainter] used by the editor, wired to this harness's
/// notifiers exactly as `pageOverlaysBuilder` wires it.
CustomPainter buildPainter() => _PageOverlayPainter(
overlayRepaint: overlayRepaint,
liveStrokeVN: _liveStrokeVN,
pageIndex: pageIndex,
strokes: const [],
highlights: const [],
pageSize: const Size(100, 100),
thinning: kDefaultPenThinning,
);
void dispose() {
overlayRepaint.dispose();
_liveStrokeVN.dispose();
}
}
/// Floating action bar shown while PDF text is selected (OneNote-style).
class _SelectionActionBar extends StatelessWidget {
const _SelectionActionBar({
required this.onHighlight,
required this.onBookmark,
});
final VoidCallback onHighlight;
final VoidCallback onBookmark;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
return Center(
child: Material(
elevation: 4,
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
onPressed: onHighlight,
icon: const Icon(Icons.highlight, size: 18),
label: Text(l.actionHighlightSelection),
),
TextButton.icon(
onPressed: onBookmark,
icon: const Icon(Icons.bookmark_add_outlined, size: 18),
label: Text(l.toolAddBookmark),
),
],
),
),
),
);
}
}
/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
/// Tap opens the sticky overlay; long-press deletes the anchor.
class _ScratchLinkMarker extends StatelessWidget {
const _ScratchLinkMarker({required this.onTap, required this.onLongPress});
final VoidCallback onTap;
final VoidCallback onLongPress;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onLongPress: onLongPress,
child: Material(
color: cs.tertiaryContainer,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: cs.outlineVariant),
),
child: Icon(
Icons.sticky_note_2,
size: 20,
color: cs.onTertiaryContainer,
),
),
);
}
}
/// Empty-space placement layer for the TEXT tool. Resolves the two requested
/// gestures by POINTER KIND (the user asked for "pen-tap OR mouse double-click"):
/// * stylus / touch → a single tap places (one deliberate pen poke);
/// * mouse → a DOUBLE-click places (a single click is too easy to trigger
/// while panning, matching the "鼠标双击" request).
/// The down-pointer's kind is captured in [onTapDown] and consumed by
/// [onTapUp]; mouse double-clicks come through [onDoubleTapDown].
class _TextPlacementLayer extends StatefulWidget {
const _TextPlacementLayer({required this.onPlace});
/// Called with the LOCAL position (within the page rect) where a box should
/// be placed.
final void Function(Offset local) onPlace;
@override
State<_TextPlacementLayer> createState() => _TextPlacementLayerState();
}
class _TextPlacementLayerState extends State<_TextPlacementLayer> {
PointerDeviceKind? _downKind;
Offset? _downLocal;
/// A down→up drift beyond this (px) means the gesture was a scroll/pan
/// (e.g. a 1-finger drag that also reaches this translucent layer), not a
/// deliberate tap-to-place.
static const double _kMaxTapDriftPx = 12.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
// translucent: this layer must NOT swallow the touch/scroll gesture from
// pdfrx underneath (only opaque'd taps that resolve to a genuine
// tap-to-place, guarded by the drift check below, actually place a box).
behavior: HitTestBehavior.translucent,
onTapDown: (d) {
_downKind = d.kind;
_downLocal = d.localPosition;
},
onTapUp: (d) {
// A mouse single-click does NOT place (mouse uses double-click); pen and
// touch place on a single tap.
if (_downKind == PointerDeviceKind.mouse) return;
final down = _downLocal;
if (down != null &&
(d.localPosition - down).distance > _kMaxTapDriftPx) {
return;
}
widget.onPlace(d.localPosition);
},
onDoubleTapDown: (d) {
_downLocal = d.localPosition;
},
onDoubleTap: () {
final local = _downLocal;
if (local != null) widget.onPlace(local);
},
);
}
}
/// A committed text annotation rendered glued to the page. Read-only label;
/// tapping it (when [onTap] is non-null, i.e. the TEXT tool is active) re-opens
/// it for editing.
class _TextAnnotationLabel extends StatelessWidget {
const _TextAnnotationLabel({
required this.text,
required this.fontSizePx,
required this.color,
this.fontWeight = FontWeight.w400,
this.fontFamily,
this.onTap,
this.onDoubleTap,
this.onPanUpdate,
});
final String text;
final double fontSizePx;
final Color color;
final FontWeight fontWeight;
final String? fontFamily;
/// Single-tap handler (only wired while the TEXT tool is active).
final VoidCallback? onTap;
/// Double-tap handler: ALWAYS wired (regardless of active tool) so a box can
/// be reopened for editing at any time.
final VoidCallback? onDoubleTap;
/// TEXT tool drag-to-move (only wired while the TEXT tool is active).
final GestureDragUpdateCallback? onPanUpdate;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onDoubleTap: onDoubleTap,
onPanUpdate: onPanUpdate,
child: Text(
text,
style: TextStyle(
fontSize: fontSizePx,
color: color,
fontWeight: fontWeight,
fontFamily: fontFamily ?? kAnnotationFontFamily,
height: 1.2,
),
),
);
}
}
/// The active editing field for a text box. A REAL Flutter [TextField] so the
/// OS IME and — on Windows — the Windows-Ink handwriting panel feed it
/// automatically. Does NOT autofocus on insert (Windows tablets otherwise pop
/// the soft keyboard on every pen tap that places a box); focus only after an
/// explicit tap on the field.
class _TextAnnotationField extends StatefulWidget {
const _TextAnnotationField({
super.key,
required this.initialText,
required this.fontSizePx,
required this.color,
required this.hintText,
required this.onChanged,
required this.onDone,
this.fontWeight = FontWeight.w400,
this.fontFamily,
});
final String initialText;
final double fontSizePx;
final Color color;
final String hintText;
final ValueChanged<String> onChanged;
final VoidCallback onDone;
final FontWeight fontWeight;
final String? fontFamily;
@override
State<_TextAnnotationField> createState() => _TextAnnotationFieldState();
}
class _TextAnnotationFieldState extends State<_TextAnnotationField> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialText);
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (!_focusNode.hasFocus) widget.onDone();
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surface.withValues(alpha: 0.85),
elevation: 1,
borderRadius: BorderRadius.circular(4),
child: TextField(
controller: _controller,
focusNode: _focusNode,
autofocus: false,
maxLines: null,
minLines: 1,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
cursorColor: widget.color,
style: TextStyle(
fontSize: widget.fontSizePx,
color: widget.color,
fontWeight: widget.fontWeight,
fontFamily: widget.fontFamily ?? kAnnotationFontFamily,
height: 1.2,
),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: widget.hintText,
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
),
onChanged: widget.onChanged,
onTapOutside: (_) => _focusNode.unfocus(),
onEditingComplete: () => _focusNode.unfocus(),
),
);
}
}