From db6e3842c7f679308481794aa339020c15c3e290 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Wed, 24 Jun 2026 02:32:41 +0800 Subject: [PATCH] feat(pdf): rebuild editor on vector PdfViewer Replace the single-page PdfPageView bitmap with a pdfrx PdfViewer: real vector text, continuous scroll, native pinch-zoom (no custom zoom solver, so no zoom-jump here). Ink is glued per-page via pageOverlaysBuilder; the pen is captured at the viewer level by PenCaptureRegion while touch falls through to scroll / pinch / text-select. Add select-text -> highlight via PdfTextSelectionParams: the selection's fragment rects are stored as normalized page rects and drawn under the ink. In-memory only for now. Per-page persistence, undo/redo, tools, colors, thumbnails and pen settings are reused verbatim. analyze clean, 270 tests green. --- lib/editor/canvas/pen_editor_screen.dart | 988 ++++++++++++++--------- lib/l10n/app_en.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_zh.dart | 6 + lib/l10n/app_zh.arb | 2 + 6 files changed, 650 insertions(+), 366 deletions(-) diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 4a951f3..3da188a 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -1,15 +1,32 @@ // lib/editor/canvas/pen_editor_screen.dart // -// Page-based pen-first PDF editor. Opens a PDF with pdfrx's document API, -// shows ONE page at a time as a bitmap (PdfPageView — a per-page widget that -// renders to an image and does NOT capture pan/zoom gestures), overlaid by the -// ink layer. Both share one transform via PenCanvas. Prev/Next + jump-to-page. +// Pen-first PDF editor built on pdfrx's REAL vector PdfViewer (continuous +// scroll, native pan/zoom, selectable text), NOT the old single-page bitmap +// (PdfPageView). Three glued-to-page layers ride on top of the viewer: +// +// 1. Per-page ink overlay (pageOverlaysBuilder): committed strokes + the +// live in-progress stroke + stored text highlights, all in NORMALIZED +// page coords so they stay pinned under scroll/zoom. +// 2. A viewer-level PenCaptureRegion (viewerOverlayBuilder) that captures +// stylus events ONLY when a pen tool is active; touch/mouse fall through +// to pdfrx for scroll/zoom/text-selection. Pen samples are mapped +// global → document → (pageIndex, normalized) via the controller. +// 3. The PdfViewer itself owns text selection; a "select text" tool disables +// pen capture so the pen drives pdfrx text selection, and a +// "highlight selection" action turns the current selection into stored +// normalized highlight rects. +// +// Persistence (strokes) reuses the existing EditorRepository / SaveScheduler +// wiring verbatim. Highlights are in-memory only for now — see +// TODO(persist-highlights). +import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; import '../../l10n/app_localizations.dart'; import '../../services/database_service.dart'; +import '../engine/stroke_eraser.dart'; import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import '../engine/stroke_model.dart'; import '../engine/stroke_simplify.dart'; @@ -17,14 +34,16 @@ 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 kNaturalPressureGamma; -import '../layout/viewport_fit.dart'; +import '../input/pressure_curve.dart' + show PressureCurve, kNaturalPressureGamma, 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'; +import 'pen_canvas.dart' show CanvasTool; import 'pen_palette_widgets.dart'; import 'pen_stroke.dart'; @@ -59,15 +78,28 @@ class PenEditorScreen extends StatefulWidget { } class _PenEditorScreenState extends State { - PdfDocument? _document; - Object? _openError; + /// 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(); - /// 0-based current page index. + 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 = {}; @@ -77,11 +109,6 @@ class _PenEditorScreenState extends State { /// Pen input configuration (widths, finger drawing, button actions). /// Loaded asynchronously in initState; null until ready. - /// - /// NOTE: the side-button / eraser-end ACTION MAPPINGS (sideButton/eraserEnd) - /// are persisted via this controller but NOT yet consumed here — they wire - /// into the input arbiter in a later step. Only widths and fingerDrawing are - /// consumed for now. PenConfigController? _penConfig; // ── Persistence ──────────────────────────────────────────────────────────── @@ -91,30 +118,45 @@ class _PenEditorScreenState extends State { SaveScheduler? _saveScheduler; - /// One shared transform for the current page; recentred on page change so - /// each page opens fit-to-view and centered. - final TransformationController _transform = TransformationController(); + /// 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++; - /// Set when the page must be (re)centered on the next layout pass. - bool _needsCenter = true; + // ── Live stroke state (viewer-level pen capture) ──────────────────────────── - /// Live page value while dragging the page slider (null when not dragging). - double? _scrub; + /// 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; - /// Whether the page-jump slider is expanded (NOT persistent — toggled by - /// tapping the page label; collapses after a jump). - bool _showSlider = false; + /// In-progress stroke points (normalized to [_liveStrokePage]). + final List _livePoints = []; - /// Latest pen-event debug readout (kind/pressure/min/max) — shown only when - /// the diagnostic toggle is on, to inspect what Windows delivers. + /// 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; + + /// When true the "select text" tool is active: pen capture is disabled so the + /// pen falls through to pdfrx for native text selection. + bool _selectTextMode = false; + Color _color = Colors.black; bool _allowFingerDrawing = false; + /// Whether the viewer currently has a non-empty text selection (drives the + /// "highlight selection" action's enabled state). + bool _hasSelection = false; + /// Pen width as a fraction of page width (base; pressure thins it down). static const double _penWidthFraction = 0.006; static const double _highlighterWidthFraction = 0.02; @@ -127,6 +169,13 @@ class _PenEditorScreenState extends State { Colors.orange, ]; + /// True when a PEN tool (pen/highlighter/eraser) is active — pen capture is on. + /// False in select-text mode so the pen reaches pdfrx text selection. + bool get _penCaptureEnabled => !_selectTextMode; + + /// True when the eraser tool is active. + bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode; + @override void initState() { super.initState(); @@ -136,7 +185,6 @@ class _PenEditorScreenState extends State { PenInputService.instance.start(); _initPersistence(); _initPenConfig(); - _open(); } Future _initPenConfig() async { @@ -145,14 +193,9 @@ class _PenEditorScreenState extends State { controller.dispose(); return; } - // Rebuild the editor when pen settings change (width, pressure - // sensitivity, button mappings) so the live canvas reflects them. controller.addListener(_onPenConfigChanged); setState(() { _penConfig = controller; - // Adopt the persisted finger-drawing preference as the initial local - // toggle state. The local 🖐 toggle keeps working and stays in sync with - // the controller (see _toggleFingerDrawing). _allowFingerDrawing = controller.value.fingerDrawing; }); } @@ -171,7 +214,6 @@ class _PenEditorScreenState extends State { return; } _saveScheduler = scheduler; - // Load any previously persisted strokes for this document. await _loadPersistedStrokes(repo); } @@ -202,6 +244,7 @@ class _PenEditorScreenState extends State { _strokesByPage[entry.key] = entry.value; } }); + _bumpOverlay(); } } @@ -214,25 +257,6 @@ class _PenEditorScreenState extends State { return int.tryParse(hostId.substring(idx + marker.length)); } - Future _open() async { - try { - final doc = await PdfDocument.openFile(widget.pdfPath); - if (!mounted) { - doc.dispose(); - return; - } - setState(() { - _document = doc; - // Honor a requested initial page (search-result jump), clamped. - if (doc.pages.isNotEmpty) { - _pageIndex = widget.initialPage.clamp(0, doc.pages.length - 1); - } - }); - } catch (e) { - if (mounted) setState(() => _openError = e); - } - } - @override void dispose() { // Flush any pending scheduled saves before tearing down. @@ -241,68 +265,56 @@ class _PenEditorScreenState extends State { scheduler.flush(); // fire-and-forget; DB write continues in isolate scheduler.dispose(); } - _document?.dispose(); - _transform.dispose(); + _overlayRepaint.dispose(); _penConfig?.dispose(); PenInputService.instance.stop(); DiagnosticLogger.instance.stop(); super.dispose(); } - List get _currentStrokes => - _strokesByPage.putIfAbsent(_pageIndex, () => []); + // ── Stroke persistence (reused verbatim from the bitmap editor) ──────────── - void _commitStroke(PenStroke stroke) { - // Snapshot-before-change: record the pre-mutation page state for undo. - _undoFor(_pageIndex).record(List.of(_currentStrokes)); + void _commitStroke(int pageIndex, PenStroke stroke) { + _undoFor(pageIndex) + .record(List.of(_strokesByPage[pageIndex] ?? const [])); setState(() { - // Replace with a NEW list so StaticInkPainter sees a fresh identity and - // actually repaints (mutating in place would alias the old painter's list - // and shouldRepaint would see no change → committed strokes vanish). - _strokesByPage[_pageIndex] = [ - ...?_strokesByPage[_pageIndex], + // New list identity so the overlay painter sees a change. + _strokesByPage[pageIndex] = [ + ...?_strokesByPage[pageIndex], stroke, ]; }); - // Snapshot SYNCHRONOUSLY (before any await) then schedule persistence. - final snapshot = List.of(_strokesByPage[_pageIndex]!); - _schedulePageSave(_pageIndex, snapshot); + final snapshot = List.of(_strokesByPage[pageIndex]!); + _schedulePageSave(pageIndex, snapshot); + _bumpOverlay(); } - /// Replace committed stroke [index] with its surviving pieces after a partial - /// (segment) erase. An empty [replacements] list removes the stroke entirely. - void _eraseStroke(int index, List replacements) { - final list = _strokesByPage[_pageIndex]; + /// 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) { - // Snapshot-before-change: record the pre-mutation page state for undo. - _undoFor(_pageIndex).record(List.of(list)); + _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; + _strokesByPage[pageIndex] = next; } }); - // Snapshot SYNCHRONOUSLY after the mutation, then schedule persistence. - final current = _strokesByPage[_pageIndex]; + final current = _strokesByPage[pageIndex]; final snapshot = current != null ? List.of(current) : []; - _schedulePageSave(_pageIndex, snapshot); + _schedulePageSave(pageIndex, snapshot); + _bumpOverlay(); } /// Convert [strokes] to [EditorStroke]s and hand them to the save scheduler. - /// - /// Must be called synchronously (no await between the snapshot and this call) - /// so the scheduler receives an immutable copy of the in-memory state. void _schedulePageSave(int pageIndex, List strokes) { final scheduler = _saveScheduler; if (scheduler == null) return; - // Compact strokes (RDP) before persisting: a fast Surface-Pen stroke lands - // hundreds of near-collinear samples; thinning them shrinks the DB row + - // speeds reload re-rasterization (R10) with no perceptible change. The live - // in-memory strokes are untouched — only what we PERSIST is simplified. final editorStrokes = strokes .map((s) => simplifyStroke(EditorStroke.fromPenStroke(s))) .toList(); @@ -313,33 +325,19 @@ class _PenEditorScreenState extends State { ); } - void _goToPage(int index) { - final doc = _document; - if (doc == null) return; - final clamped = index.clamp(0, doc.pages.length - 1); - if (clamped == _pageIndex) return; - setState(() { - _pageIndex = clamped; - _needsCenter = true; // recenter the new page on next layout - }); - } - - /// Undo the last draw/erase on the current page, restoring and persisting - /// the previous snapshot. void _performUndo() { final stack = _undoFor(_pageIndex); if (!stack.canUndo) return; - final current = List.of(_currentStrokes); + final current = List.of(_strokesByPage[_pageIndex] ?? const []); final snapshot = stack.undo(current); if (snapshot == null) return; setState(() { - // New list identity so StaticInkPainter repaints. _strokesByPage[_pageIndex] = List.of(snapshot); }); _schedulePageSave(_pageIndex, List.of(snapshot)); + _bumpOverlay(); } - /// Redo the last undone draw/erase on the current page. void _performRedo() { final stack = _undoFor(_pageIndex); if (!stack.canRedo) return; @@ -349,46 +347,269 @@ class _PenEditorScreenState extends State { _strokesByPage[_pageIndex] = List.of(snapshot); }); _schedulePageSave(_pageIndex, List.of(snapshot)); + _bumpOverlay(); } - /// Cycle pen → highlighter → eraser → pen (for the toggleTool button action). - void _cycleTool() { - setState(() { - _tool = switch (_tool) { - CanvasTool.pen => CanvasTool.highlighter, - CanvasTool.highlighter => CanvasTool.eraser, - CanvasTool.eraser => CanvasTool.pen, - }; - }); + // ── 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). + const floor = kNaturalPressureFloor; + final gamma = _penConfig?.value.pressureGamma ?? kNaturalPressureGamma; + return PressureCurve(floor: floor, gamma: gamma).apply(raw); } - /// Handle a hardware pen-button action delivered by [PenCanvas] (W3). - /// `eraser` and `pan` are handled inside the canvas; here we map the - /// edge-triggered ones. - void _handlePenButtonAction(PenButtonAction action) { - switch (action) { - case PenButtonAction.undo: - _performUndo(); - case PenButtonAction.toggleTool: - _cycleTool(); - case PenButtonAction.eraser: - case PenButtonAction.pan: - case PenButtonAction.none: - break; + 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); } } - /// Toggle finger-drawing, keeping the local state and the persisted config - /// (when loaded) in sync. + void _updateLiveStroke() { + final page = _liveStrokePage; + if (page == null || _livePoints.isEmpty) return; + _liveStroke = PenStroke( + points: List.of(_livePoints), + color: _currentColor().toARGB32(), + width: _currentStrokeWidth(), + kind: _currentKind(), + ); + _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(), + ), + ); + } + _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; + + 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; + }); + } + + void _enableSelectText() { + setState(() => _selectTextMode = true); + } + void _toggleFingerDrawing() { final next = !_allowFingerDrawing; setState(() => _allowFingerDrawing = next); _penConfig?.setFingerDrawing(next); } - /// Open the page thumbnail grid; tapping a thumbnail navigates to that page. void _openThumbnails() { - final doc = _document; + final doc = _controller.isReady ? _controller.document : null; if (doc == null) return; showPageThumbnailSheet( context, @@ -398,20 +619,13 @@ class _PenEditorScreenState extends State { ); } - /// Open the pen settings sheet (widths, pressure, finger drawing, etc.). void _openPenSettings() { final config = _penConfig; if (config == null) return; showPenSettingsSheet(context, config); } - /// Centre [pageSize] within [viewport] via the shared transform. Uses the - /// shared, unit-tested [centerOffset] (pageSize is already fit to the viewport - /// at scale 1, so we center at scale 1). - void _centerPage(Size viewport, Size pageSize) { - final o = centerOffset(pageSize, viewport, 1.0); - _transform.value = Matrix4.identity()..setTranslationRaw(o.dx, o.dy, 0); - } + // ── Build ────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { @@ -419,7 +633,7 @@ class _PenEditorScreenState extends State { return Scaffold( body: Stack( children: [ - Positioned.fill(child: _buildBody()), + Positioned.fill(child: _buildViewer()), // Floating Material You tool palette (top-center). SafeArea( child: Align( @@ -431,7 +645,7 @@ class _PenEditorScreenState extends State { ), ), // Floating page-control pill (bottom-center). - if (_document != null) + if (_viewerReady && _pageCount > 0) SafeArea( child: Align( alignment: Alignment.bottomCenter, @@ -452,163 +666,82 @@ class _PenEditorScreenState extends State { ), ), ), - // Pen diagnostic readout (top-right) — shows what Windows delivers. - if (_showPenDebug) - 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)), - ), - ), - ], - ); - }, - ), - ), - ), - ), - ), - ), - ), + if (_showPenDebug) _buildDebugReadout(context), ], ), ); } - Widget _buildBody() { - final l = AppLocalizations.of(context); - if (_openError != null) { - return Center(child: Text(l.failedToOpenPdf('$_openError'))); - } - final doc = _document; - if (doc == null) { - return const Center(child: CircularProgressIndicator()); - } - if (doc.pages.isEmpty) { - return Center(child: Text(l.pdfNoPages)); - } - - final page = doc.pages[_pageIndex]; - - return LayoutBuilder( - builder: (context, constraints) { - // Fit the page rectangle into the available viewport at scale 1.0; the - // InteractiveViewer then zooms/pans from there. Ink normalized coords - // map onto this rectangle. - final fit = (constraints.maxWidth / page.width) - .clamp(0.0, double.infinity); - final fitH = constraints.maxHeight / page.height; - final scale = fit < fitH ? fit : fitH; - final pageSize = Size(page.width * scale, page.height * scale); - - if (_needsCenter) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _centerPage( - Size(constraints.maxWidth, constraints.maxHeight), pageSize); - setState(() => _needsCenter = false); + 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; }); - } - return PenCanvas( - key: ValueKey(_pageIndex), - pageSize: pageSize, - strokes: _currentStrokes, - transformationController: _transform, - tool: _tool, - color: _color, - strokeWidth: _tool == CanvasTool.highlighter - ? (_penConfig?.value.highlighterWidth ?? - _highlighterWidthFraction) - : (_penConfig?.value.penWidth ?? _penWidthFraction), - thinning: - _penConfig?.value.pressureSensitivity ?? kDefaultPenThinning, - // Pressure-response shaping (the rnote-like feel). The pen-settings - // gamma slider now actually drives stroke width; fall back to the - // natural default when no config is loaded yet. - pressureGamma: - _penConfig?.value.pressureGamma ?? kNaturalPressureGamma, - eraserRadius: - _penConfig?.value.eraserRadius ?? kDefaultEraserRadius, - eraserWholeStroke: _penConfig?.value.eraserWholeStroke ?? false, - sideButtonAction: - _penConfig?.value.sideButton ?? PenButtonAction.eraser, - eraserEndAction: - _penConfig?.value.eraserEnd ?? PenButtonAction.eraser, - onPenButtonAction: _handlePenButtonAction, - allowFingerDrawing: _allowFingerDrawing, - onPenDebug: _showPenDebug - ? (s) => setState(() => _penDebug = s) - : null, - onStrokeComplete: _commitStroke, - onEraseStroke: _eraseStroke, - pageWidget: PdfPageView( - document: doc, - pageNumber: _pageIndex + 1, - // Fill the SizedBox exactly so ink aligns to the page rect (no - // internal letterboxing offset). - pageSizeCallback: (biggest, page, rotation) => biggest, - decoration: const BoxDecoration(color: Colors.white), - backgroundColor: Colors.white, + // Honor a requested initial page (search-result jump), clamped. + final target = widget.initialPage.clamp(0, _pageCount - 1); + if (target > 0) { + controller.goToPage(pageNumber: target + 1); + } + }, + onPageChanged: (pageNumber) { + if (pageNumber == null || !mounted) return; + final idx = pageNumber - 1; + if (idx != _pageIndex) setState(() => _pageIndex = idx); + }, + // (1) Per-page overlay: committed ink + live stroke + highlights, all in + // normalized page space scaled to the on-screen page rect. + pageOverlaysBuilder: (context, pageRectInViewer, page) { + final pageIndex = page.pageNumber - 1; + return [ + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _PageOverlayPainter( + repaint: _overlayRepaint, + strokes: _strokesByPage[pageIndex] ?? const [], + highlights: _highlightsByPage[pageIndex] ?? const [], + liveStroke: + _liveStrokePage == pageIndex ? _liveStroke : null, + pageSize: pageRectInViewer.size, + thinning: _penConfig?.value.pressureSensitivity ?? + kDefaultPenThinning, + ), + ), + ), ), - ); - }, + ]; + }, + // (2) Viewer-level pen capture. Stylus is captured ONLY when a pen tool + // is active; touch/mouse (and pen in select-text mode) fall through to + // pdfrx for scroll/zoom/text-selection. + viewerOverlayBuilder: (context, size, handleLinkTap) { + return [ + Positioned.fill( + child: PenCaptureRegion( + captureEnabled: _penCaptureEnabled, + onPenEvent: _onPenEvent, + child: const IgnorePointer(child: SizedBox.expand()), + ), + ), + ]; + }, + ), ); } - /// Floating Material You tool palette: a tonal rounded surface holding the - /// tools, color dots, and finger-drawing toggle. + /// Floating Material You tool palette. Widget _buildToolPalette() { final cs = Theme.of(context).colorScheme; final l = AppLocalizations.of(context); @@ -623,21 +756,35 @@ class _PenEditorScreenState extends State { children: [ ToolButton( icon: Icons.edit_outlined, - selected: _tool == CanvasTool.pen, + selected: _tool == CanvasTool.pen && !_selectTextMode, tooltip: l.toolPen, - onPressed: () => setState(() => _tool = CanvasTool.pen), + onPressed: () => _setTool(CanvasTool.pen), ), ToolButton( icon: Icons.brush_outlined, - selected: _tool == CanvasTool.highlighter, + selected: _tool == CanvasTool.highlighter && !_selectTextMode, tooltip: l.toolHighlighter, - onPressed: () => setState(() => _tool = CanvasTool.highlighter), + onPressed: () => _setTool(CanvasTool.highlighter), ), ToolButton( icon: Icons.cleaning_services_outlined, - selected: _tool == CanvasTool.eraser, + selected: _isEraser, tooltip: l.toolEraser, - onPressed: () => setState(() => _tool = CanvasTool.eraser), + onPressed: () => _setTool(CanvasTool.eraser), + ), + PaletteDivider(cs: cs), + // Text selection + highlight (real vector text). + ToolButton( + icon: Icons.text_fields, + selected: _selectTextMode, + tooltip: l.toolSelectText, + onPressed: _enableSelectText, + ), + ToolButton( + icon: Icons.highlight, + selected: false, + tooltip: l.actionHighlightSelection, + onPressed: _hasSelection ? _highlightSelection : null, ), PaletteDivider(cs: cs), // Undo / redo (per page). @@ -659,19 +806,16 @@ class _PenEditorScreenState extends State { ToolButton( icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch, selected: _allowFingerDrawing, - tooltip: _allowFingerDrawing - ? l.fingerDrawingOn - : l.fingerDrawingOff, + tooltip: + _allowFingerDrawing ? l.fingerDrawingOn : l.fingerDrawingOff, onPressed: _toggleFingerDrawing, ), - // Page thumbnail grid. ToolButton( icon: Icons.grid_view, selected: false, tooltip: l.pages, - onPressed: _document != null ? _openThumbnails : null, + onPressed: _viewerReady ? _openThumbnails : null, ), - // Pen settings. ToolButton( icon: Icons.settings_outlined, selected: false, @@ -720,87 +864,199 @@ class _PenEditorScreenState extends State { ); } - /// Floating page control: a COMPACT pill (prev / "n / total" / next). Tapping - /// the label reveals a drag-slider — which is NOT persistent (collapses again - /// on tap) so it doesn't block the page. No keyboard input (Windows IME is - /// unreliable). + /// Floating page control: a COMPACT pill (prev / "n / total" / next). Widget _buildPagePill() { - final doc = _document!; final cs = Theme.of(context).colorScheme; final l = AppLocalizations.of(context); - final total = doc.pages.length; - final shown = (_scrub ?? (_pageIndex + 1).toDouble()).round(); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Slider — shown only when expanded (not persistent). - if (_showSlider && total > 1) - Container( - margin: const EdgeInsets.only(bottom: 8), - constraints: const BoxConstraints(maxWidth: 420), - child: Material( - color: cs.surfaceContainerHigh, - elevation: 3, - borderRadius: BorderRadius.circular(28), + 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: 12), - child: Slider( - min: 1, - max: total.toDouble(), - value: (_scrub ?? (_pageIndex + 1).toDouble()) - .clamp(1, total.toDouble()), - label: '$shown', - divisions: total - 1, - onChanged: (v) => setState(() => _scrub = v), - onChangeEnd: (v) { - setState(() => _scrub = null); - _goToPage(v.round() - 1); + 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)), + ), + ), + ], + ); }, ), ), ), ), - // Compact pill — always; fits content (no big frame). - 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: total > 1 - ? () => setState(() => _showSlider = !_showSlider) - : 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, - ), - ], - ), - ), ), - ], + ), ); } } +/// 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; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 323b9fa..c27ef60 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -56,6 +56,8 @@ "back": "Back", "previousPage": "Previous page", "nextPage": "Next page", + "toolSelectText": "Select text", + "actionHighlightSelection": "Highlight selection", "failedToOpenPdf": "Failed to open PDF:\n{error}", "@failedToOpenPdf": { "placeholders": { "error": { "type": "String" } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index dfa71e3..aa10cd2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -380,6 +380,18 @@ abstract class AppLocalizations { /// **'Next page'** String get nextPage; + /// No description provided for @toolSelectText. + /// + /// In en, this message translates to: + /// **'Select text'** + String get toolSelectText; + + /// No description provided for @actionHighlightSelection. + /// + /// In en, this message translates to: + /// **'Highlight selection'** + String get actionHighlightSelection; + /// No description provided for @failedToOpenPdf. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 855d31c..d389313 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -155,6 +155,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nextPage => 'Next page'; + @override + String get toolSelectText => 'Select text'; + + @override + String get actionHighlightSelection => 'Highlight selection'; + @override String failedToOpenPdf(String error) { return 'Failed to open PDF:\n$error'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e6d402d..ced6a57 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -155,6 +155,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get nextPage => '下一页'; + @override + String get toolSelectText => '选择文字'; + + @override + String get actionHighlightSelection => '高亮所选'; + @override String failedToOpenPdf(String error) { return '打开 PDF 失败:\n$error'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e8b1197..1cdc14f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -47,6 +47,8 @@ "back": "返回", "previousPage": "上一页", "nextPage": "下一页", + "toolSelectText": "选择文字", + "actionHighlightSelection": "高亮所选", "failedToOpenPdf": "打开 PDF 失败:\n{error}", "pdfNoPages": "PDF 没有任何页面。", "pageOfPages": "{current} / {total}"