// 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 'package:uuid/uuid.dart'; import '../../l10n/app_localizations.dart'; import '../../models/scratch_link.dart'; import '../../screens/split_view_screen.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. /// 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; 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 createState() => _PenEditorScreenState(); } class _PenEditorScreenState extends State { /// 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> _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> _highlightsByPage = {}; /// Per-page undo/redo history. Snapshot-before-change discipline: the /// pre-mutation stroke list is recorded before each commit/erase. final Map>> _undo = {}; UndoStack> _undoFor(int page) => _undo.putIfAbsent(page, () => UndoStack>()); /// 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 _overlayRepaint = ValueNotifier(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 _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; /// 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; /// All scratch-link anchors for this document, loaded on open and updated on /// add/delete. Rendered as tappable markers in [pageOverlaysBuilder]. final List _scratchLinks = []; static const _uuid = Uuid(); 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 _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 (pen reaches pdfrx text selection) and in /// place-link mode (a tap drops an anchor via the page overlay). bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode; /// 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 _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 _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); await _loadScratchLinks(service); } /// Load this document's scratch-link anchors into [_scratchLinks]. Future _loadScratchLinks(DatabaseService service) async { final links = await service.loadScratchLinks(_documentId); if (!mounted) return; setState(() { _scratchLinks ..clear() ..addAll(links); }); } /// Load all persisted strokes for [_documentId] and populate [_strokesByPage]. Future _loadPersistedStrokes(EditorRepository repo) async { final hosted = await repo.loadDocument(_documentId); if (!mounted) return; final loaded = >{}; 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::page:"`. 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.of(_strokesByPage[pageIndex] ?? const [])); setState(() { // New list identity so the overlay painter sees a change. _strokesByPage[pageIndex] = [ ...?_strokesByPage[pageIndex], stroke, ]; }); final snapshot = List.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 replacements) { final list = _strokesByPage[pageIndex]; final willMutate = list != null && index >= 0 && index < list.length; if (willMutate) { _undoFor(pageIndex).record(List.of(list)); } setState(() { if (list != null && index >= 0 && index < list.length) { final next = List.of(list) ..replaceRange(index, index + 1, replacements); _strokesByPage[pageIndex] = next; } }); final current = _strokesByPage[pageIndex]; final snapshot = current != null ? List.of(current) : []; _schedulePageSave(pageIndex, snapshot); _bumpOverlay(); } /// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler. void _schedulePageSave(int pageIndex, List 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.of(_strokesByPage[_pageIndex] ?? const []); final snapshot = stack.undo(current); if (snapshot == null) return; setState(() { _strokesByPage[_pageIndex] = List.of(snapshot); }); _schedulePageSave(_pageIndex, List.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.of(snapshot); }); _schedulePageSave(_pageIndex, List.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 [] : 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 _highlightSelection() async { if (!_controller.isReady) return; final delegate = _controller.textSelectionDelegate; final ranges = await delegate.getSelectedTextRanges(); if (!mounted || ranges.isEmpty) return; final added = >{}; 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] ??= []).add(norm); } } if (added.isEmpty) return; setState(() { for (final entry in added.entries) { (_highlightsByPage[entry.key] ??= []).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; _placeLinkMode = false; }); } void _enableSelectText() { setState(() { _selectTextMode = true; _placeLinkMode = false; }); } /// 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) _selectTextMode = false; }); } // ── 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 _placeScratchLink(int pageIndex, Offset normalized) async { final link = ScratchLink( id: _uuid.v4(), documentId: _documentId, pageIndex: pageIndex, nx: normalized.dx.clamp(0.0, 1.0), ny: normalized.dy.clamp(0.0, 1.0), ); final service = await DatabaseService.getInstance(); await service.saveScratchLink(link); if (!mounted) return; setState(() => _scratchLinks.add(link)); } /// Open the anchor's split view (left = this PDF at the anchor page, right = /// the anchor's private infinite scratchpad). void _openScratchLink(ScratchLink link) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => SplitViewScreen( filePath: widget.pdfPath, documentId: _documentId, scratchLinkId: link.id, initialPage: link.pageIndex, ), ), ); } /// Confirm + delete an anchor (and its private scratchpad). Future _confirmDeleteScratchLink(ScratchLink link) async { final l = AppLocalizations.of(context); final confirmed = await showDialog( 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; final service = await DatabaseService.getInstance(); await service.deleteScratchLink(link.id); if (!mounted) return; setState(() => _scratchLinks.removeWhere((s) => s.id == link.id)); } 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; 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( repaint: _overlayRepaint, strokes: _strokesByPage[pageIndex] ?? const [], highlights: _highlightsByPage[pageIndex] ?? const [], liveStroke: _liveStrokePage == pageIndex ? _liveStroke : 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)); }, ), ), // 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. 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), // 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), // 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 strokes; final List 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; } /// A small sticky-note "tab" marker glued to a page at a scratch-link anchor. /// Tap opens the anchor's split view; 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, ), ), ); } }