// 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 (`.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/scratch_link.dart'; import '../../screens/split_view_screen.dart'; import '../../storage/badnote_sidecar.dart'; 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/pen_settings_page.dart'; import '../ui/thumbnail_grid.dart'; import 'editor_tool.dart'; import 'ink_painters.dart' show buildStrokePath; import 'input_diagnostics.dart'; import 'pen_palette_widgets.dart'; import 'pen_stroke.dart'; import 'pinch_scale_solver.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; 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. Persisted to the sidecar (Phase 2 — highlights now survive /// reopen and can be removed via the un-highlight tool). 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 ──────────────────────────────────────────────────────────── /// 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 _overlayRepaint = ValueNotifier(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). static const double _kScaleGlitchHi = 1.4; static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi; /// A single-frame focal-midpoint jump beyond this is a touch misread → drop. static const double _kFocalGlitchPx = 250.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 _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 _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 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; /// 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; /// 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(); /// 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 _palette = [ Colors.black, Colors.red, Colors.blue, Colors.green, Colors.orange, ]; /// 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) and in place-link mode (a tap drops an anchor via the overlay). bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode && !_removeHighlightMode; /// 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(); _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 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 = >{}; 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 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(); } final loadedHighlights = >{}; 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; } _scratchLinks ..clear() ..addAll(repo.loadedScratchLinks.map((s) => s.link)); }); _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?.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 sidecar repo. void _schedulePageSave(int pageIndex, List 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.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; } 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.of(list)); setState(() { final next = List.of(list); next[sel.index] = translateStroke(next[sel.index], dx, dy); _strokesByPage[sel.page] = next; }); _schedulePageSave(sel.page, List.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.of(list)); setState(() { final next = List.of(list)..removeAt(sel.index); _strokesByPage[sel.page] = next; _selected = null; }); _schedulePageSave(sel.page, List.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: _currentBrush(), ), ); } 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 [] : 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) 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, ); _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 _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; }); // 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 []; 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.of(rects)..removeAt(i); if (next.isEmpty) { _highlightsByPage.remove(pageIndex); } else { _highlightsByPage[pageIndex] = next; } }); _scheduleHighlightSave(pageIndex); _bumpOverlay(); return true; } } return false; } // ── 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; _selectTextMode = false; _placeLinkMode = false; _removeHighlightMode = false; if (tool != EditorToolKind.select) _selected = null; }); } void _enableSelectText() { setState(() { _selectTextMode = true; _placeLinkMode = false; _removeHighlightMode = false; _selected = 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) { _selectTextMode = false; _removeHighlightMode = false; } }); } /// 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) { _selectTextMode = false; _placeLinkMode = false; _selected = 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 _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)); } /// Open the anchor's split view (left = this PDF at the anchor page, right = /// the anchor's private infinite scratchpad, stored in the sidecar). /// /// Flushes any pending sidecar write FIRST so the split view (which opens its /// own [SidecarRepository] on the same file) sees this anchor on disk before /// it writes the scratchpad back. On return, reload so any scratchpad change /// made there is reflected in this editor's in-memory sidecar repo. Future _openScratchLink(ScratchLink link) async { await _repo?.flush(); if (!mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => SplitViewScreen( filePath: widget.pdfPath, scratchLinkId: link.id, initialPage: link.pageIndex, ), ), ); if (!mounted) return; // The split view wrote the scratchpad into the on-disk sidecar via its own // repo; re-open ours so subsequent saves here don't clobber that scratchpad. final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf'); if (!mounted) { repo.dispose(); return; } _repo?.dispose(); _repo = repo; } /// 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; _repo?.scheduleScratchLinkDelete(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( // 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)); }, ), ), // 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()), ), ), ]; }, ), ); } /// 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, ), 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), // 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) { // 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 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)), ), ), ], ); }, ), ), ), ), ), ), ); } } /// 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 _pointers = {}; @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 strokes; final List 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; canvas.drawPath( path, Paint() ..color = Color(stroke.color) ..style = PaintingStyle.fill ..isAntiAlias = true, ); } // 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, Paint() ..color = Color(live.stroke.color) ..style = PaintingStyle.fill ..isAntiAlias = true, ); } } // 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 overlayRepaint = ValueNotifier(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(); } } /// 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, ), ), ); } }