From 991cc7ed3c0512f41cdcfef54b67891094b9cb5e Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Mon, 26 Oct 2026 21:37:04 +0200 Subject: [PATCH 1/4] feat: add reader selections, highlights and unobtrusive controls --- example/test/widget_test.dart | 7 + lib/papyrus_reader.dart | 1 + lib/src/domain/reader_preferences.dart | 2 +- lib/src/domain/reader_selection.dart | 82 ++++ lib/src/engine/epub/epub_rich_layout.dart | 175 ++++++++- lib/src/engine/epub/epub_viewport.dart | 367 ++++++++++++++--- lib/src/engine/pdf/pdf_facade.dart | 260 +++++++++--- lib/src/engine/pdf/pdf_selection_toolbar.dart | 189 +++++++++ lib/src/presentation/papyrus_reader.dart | 121 ++++-- lib/src/presentation/reader_controls.dart | 3 + .../reader_highlight_interaction.dart | 143 +++++++ .../reader_interaction_scope.dart | 18 +- lib/src/presentation/reader_surface.dart | 16 + .../presentation/reader_swipe_tracker.dart | 71 ++++ lib/src/presentation/reader_text_toolbar.dart | 32 ++ lib/src/presentation/reader_theme_data.dart | 4 +- lib/src/presentation/reader_ui_builders.dart | 12 +- test/domain/reader_preferences_test.dart | 2 +- test/engine/epub_rich_layout_test.dart | 46 +++ test/engine/pdf_highlight_test.dart | 124 ++++++ test/engine/pdf_reader_engine_test.dart | 12 +- test/presentation/epub_reading_flow_test.dart | 2 + test/presentation/epub_selection_test.dart | 371 ++++++++++++++++++ test/presentation/papyrus_reader_test.dart | 114 +++++- test/presentation/pdf_selection_test.dart | 148 +++++++ .../reader_swipe_tracker_test.dart | 124 ++++++ .../reader_text_toolbar_test.dart | 53 +++ test/presentation/real_book_reading_test.dart | 5 + 28 files changed, 2333 insertions(+), 171 deletions(-) create mode 100644 lib/src/domain/reader_selection.dart create mode 100644 lib/src/engine/pdf/pdf_selection_toolbar.dart create mode 100644 lib/src/presentation/reader_highlight_interaction.dart create mode 100644 lib/src/presentation/reader_swipe_tracker.dart create mode 100644 lib/src/presentation/reader_text_toolbar.dart create mode 100644 test/engine/pdf_highlight_test.dart create mode 100644 test/presentation/epub_selection_test.dart create mode 100644 test/presentation/pdf_selection_test.dart create mode 100644 test/presentation/reader_swipe_tracker_test.dart create mode 100644 test/presentation/reader_text_toolbar_test.dart diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index dd448b1..75d06a4 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus_reader/papyrus_reader.dart'; import 'package:papyrus_reader_example/main.dart'; @@ -30,6 +31,9 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(PapyrusReader), findsOneWidget); + expect(find.byType(Slider), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); final progressSlider = tester.widget(find.byType(Slider)); progressSlider.onChangeEnd!(0.65); @@ -61,6 +65,9 @@ void main() { await tester.tap(readEpub); await tester.pumpAndSettle(); expect(find.byType(PapyrusReader), findsOneWidget); + expect(find.byType(Slider), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); expect(tester.widget(find.byType(Slider)).value, 1.0); await tester.tap(find.byTooltip('Reading settings')); diff --git a/lib/papyrus_reader.dart b/lib/papyrus_reader.dart index 7da16b2..1af7141 100644 --- a/lib/papyrus_reader.dart +++ b/lib/papyrus_reader.dart @@ -6,6 +6,7 @@ export 'src/domain/reader_activity.dart'; export 'src/domain/reader_document.dart'; export 'src/domain/reader_exception.dart'; export 'src/domain/reader_locator.dart'; +export 'src/domain/reader_selection.dart'; export 'src/domain/reader_preferences.dart'; export 'src/domain/reader_snapshot.dart'; export 'src/domain/reader_toc_entry.dart'; diff --git a/lib/src/domain/reader_preferences.dart b/lib/src/domain/reader_preferences.dart index 77ad58d..b82ef7f 100644 --- a/lib/src/domain/reader_preferences.dart +++ b/lib/src/domain/reader_preferences.dart @@ -16,7 +16,7 @@ final class ReaderPreferences { this.foregroundColor = const Color(0xff1b1b1b), this.brightness = Brightness.light, this.layoutMode = ReaderLayoutMode.paginated, - this.columnMode = ReaderColumnMode.automatic, + this.columnMode = ReaderColumnMode.single, }) : assert(fontSize > 0), assert(lineHeight > 0), assert(paragraphSpacing >= 0); diff --git a/lib/src/domain/reader_selection.dart b/lib/src/domain/reader_selection.dart new file mode 100644 index 0000000..913e0d6 --- /dev/null +++ b/lib/src/domain/reader_selection.dart @@ -0,0 +1,82 @@ +import 'package:flutter/painting.dart'; + +import 'reader_locator.dart'; + +/// Durable text anchors. EPUB uses chapter-local UTF-16 offsets, PDF uses page rectangles. +final class ReaderSelection { + const ReaderSelection({ + required this.text, + required this.start, + required this.end, + this.rectangles = const [], + }); + + final String text; + final ReaderLocator start; + final ReaderLocator end; + final List rectangles; + + Map toJson() => { + 'text': text, + 'start': start.toJson(), + 'end': end.toJson(), + 'rectangles': [for (final rectangle in rectangles) rectangle.toJson()], + }; + + factory ReaderSelection.fromJson(Map json) => + ReaderSelection( + text: json['text'] as String, + start: ReaderLocator.fromJson( + Map.from(json['start'] as Map), + ), + end: ReaderLocator.fromJson( + Map.from(json['end'] as Map), + ), + rectangles: [ + for (final rectangle in json['rectangles'] as List? ?? const []) + ReaderSelectionRect.fromJson( + Map.from(rectangle as Map), + ), + ], + ); +} + +final class ReaderSelectionRect { + const ReaderSelectionRect({required this.pageIndex, required this.rect}); + + final int pageIndex; + + /// Coordinates relative to page width and height, with a top-left origin. + final Rect rect; + + Map toJson() => { + 'pageIndex': pageIndex, + 'left': rect.left, + 'top': rect.top, + 'right': rect.right, + 'bottom': rect.bottom, + }; + + factory ReaderSelectionRect.fromJson(Map json) => + ReaderSelectionRect( + pageIndex: json['pageIndex'] as int, + rect: Rect.fromLTRB( + (json['left'] as num).toDouble(), + (json['top'] as num).toDouble(), + (json['right'] as num).toDouble(), + (json['bottom'] as num).toDouble(), + ), + ); +} + +final class ReaderHighlight { + const ReaderHighlight({ + required this.id, + required this.selection, + required this.color, + }); + + final String id; + final ReaderSelection selection; + final Color color; +} diff --git a/lib/src/engine/epub/epub_rich_layout.dart b/lib/src/engine/epub/epub_rich_layout.dart index 2446e1f..842f655 100644 --- a/lib/src/engine/epub/epub_rich_layout.dart +++ b/lib/src/engine/epub/epub_rich_layout.dart @@ -2,8 +2,11 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; import '../../domain/reader_preferences.dart'; +import '../../domain/reader_selection.dart'; +import '../../domain/reader_locator.dart'; import '../../presentation/reader_interaction_scope.dart'; final class EpubContentBlock { @@ -49,7 +52,12 @@ final class EpubContentBlock { ); } - TextSpan span(ReaderPreferences preferences, {int start = 0, int? end}) { + TextSpan span( + ReaderPreferences preferences, { + int start = 0, + int? end, + List highlights = const [], + }) { final children = []; var position = 0; for (final run in runs) { @@ -57,20 +65,58 @@ final class EpubContentBlock { final from = math.max(0, start - position); final to = math.min(text.length, (end ?? length) - position); if (to > from) { - children.add( - TextSpan( - text: text.substring(from, to), - style: TextStyle( - fontWeight: run['bold'] == true ? FontWeight.bold : null, - fontStyle: run['italic'] == true ? FontStyle.italic : null, - fontFamily: run['code'] == true ? 'monospace' : null, - decoration: TextDecoration.combine([ - if (run['underline'] == true) TextDecoration.underline, - if (run['strike'] == true) TextDecoration.lineThrough, - ]), + final cuts = {from, to}; + + for (final highlight in highlights) { + final first = highlight.selection.start; + final last = highlight.selection.end; + + if (first is EpubReaderLocator && + last is EpubReaderLocator && + first.textOffset != null && + last.textOffset != null) { + cuts.add((first.textOffset! - offset - position).clamp(from, to)); + cuts.add((last.textOffset! - offset - position).clamp(from, to)); + } + } + + final boundaries = cuts.toList()..sort(); + + for (var index = 0; index < boundaries.length - 1; index++) { + final segmentStart = boundaries[index]; + Color? background; + + for (final highlight in highlights) { + final first = highlight.selection.start; + final last = highlight.selection.end; + final absolute = offset + position + segmentStart; + + if (first is EpubReaderLocator && + last is EpubReaderLocator && + first.textOffset != null && + last.textOffset != null && + absolute >= first.textOffset! && + absolute < last.textOffset!) { + background = highlight.color.withValues(alpha: .4); + } + } + + children.add( + TextSpan( + text: text.substring(segmentStart, boundaries[index + 1]), + style: TextStyle( + backgroundColor: background, + fontWeight: run['bold'] == true ? FontWeight.bold : null, + fontStyle: run['italic'] == true ? FontStyle.italic : null, + fontFamily: run['code'] == true ? 'monospace' : null, + decoration: TextDecoration.combine([ + if (run['underline'] == true) TextDecoration.underline, + if (run['strike'] == true) TextDecoration.lineThrough, + ]), + ), ), - ), - ); + ); + } } position += text.length; } @@ -193,8 +239,9 @@ Future> layoutEpubChapter( Widget buildEpubFragment( EpubPageFragment fragment, - ReaderPreferences preferences, -) { + ReaderPreferences preferences, { + List highlights = const [], +}) { final block = fragment.block; if (block.image != null) { return SizedBox( @@ -212,8 +259,17 @@ Widget buildEpubFragment( ), ); } - final text = Text.rich( - block.span(preferences, start: fragment.start, end: fragment.end), + final text = EpubSelectionFragment( + key: ValueKey(fragment.offset), + fragment: fragment, + child: Text.rich( + block.span( + preferences, + start: fragment.start, + end: fragment.end, + highlights: highlights, + ), + ), ); if (!block.runs.any((run) => run['link'] == true)) { return text; @@ -254,3 +310,86 @@ Widget buildEpubFragment( ), ); } + +final class EpubSelectionFragment extends StatefulWidget { + const EpubSelectionFragment({ + required this.fragment, + required this.child, + super.key, + }); + final EpubPageFragment fragment; + final Widget child; + + @override + State createState() => EpubSelectionFragmentState(); +} + +final class EpubSelectionFragmentState extends State + implements SelectionRegistrar { + final _selectables = {}; + SelectionRegistrar? _parent; + + SelectedContentRange? get range { + for (final selectable in _selectables) { + final selection = selectable.getSelection(); + if (selection != null) return selection; + } + return null; + } + + List rectangles(int start, int end) { + RenderParagraph? paragraph; + void visit(RenderObject object) { + if (object is RenderParagraph) { + paragraph = object; + } else { + object.visitChildren(visit); + } + } + + final object = context.findRenderObject(); + if (object != null) visit(object); + final text = paragraph; + if (text == null || !text.attached) return const []; + final length = widget.fragment.end - widget.fragment.start; + final selection = TextSelection( + baseOffset: start.clamp(0, length), + extentOffset: end.clamp(0, length), + ); + return [ + for (final box in text.getBoxesForSelection(selection)) + Rect.fromPoints( + text.localToGlobal(box.toRect().topLeft), + text.localToGlobal(box.toRect().bottomRight), + ), + ]; + } + + @override + void add(Selectable selectable) { + _selectables.add(selectable); + _parent?.add(selectable); + } + + @override + void remove(Selectable selectable) { + _selectables.remove(selectable); + _parent?.remove(selectable); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final parent = SelectionContainer.maybeOf(context); + if (identical(parent, _parent)) return; + for (final selectable in _selectables) { + _parent?.remove(selectable); + parent?.add(selectable); + } + _parent = parent; + } + + @override + Widget build(BuildContext context) => + SelectionRegistrarScope(registrar: this, child: widget.child); +} diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index 71b2541..dffcc1d 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -3,10 +3,15 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import '../../domain/reader_selection.dart'; +import '../../domain/reader_locator.dart'; import '../../domain/reader_types.dart'; import '../../domain/reader_preferences.dart'; import '../../presentation/reader_interaction_scope.dart'; +import '../../presentation/reader_swipe_tracker.dart'; +import '../../presentation/reader_highlight_interaction.dart'; +import '../../presentation/reader_text_toolbar.dart'; import 'epub_reader_engine.dart'; import 'epub_rich_layout.dart'; import 'epub_content_renderer.dart'; @@ -39,13 +44,194 @@ final class _EpubViewportState extends State { int _restoration = -1; int _pageIndex = 0; int _columns = 1; - double _drag = 0; + final _swipe = ReaderSwipeTracker(); int _startPage = 0; double _startScroll = 0; bool _restoring = false; bool _busy = false; Completer? _layoutReady; List _scrollOffsets = const []; + final _selectionKey = GlobalKey(); + final _selectionMenuController = ContextMenuController(); + int _menuRevision = 0; + String _selectedText = ''; + Rect? _menuBounds; + + Iterable get _fragments { + final fragments = []; + void visit(Element element) { + if (element is StatefulElement && + element.state is EpubSelectionFragmentState) { + fragments.add(element.state as EpubSelectionFragmentState); + } + element.visitChildElements(visit); + } + + _selectionKey.currentContext?.visitChildElements(visit); + return fragments; + } + + Rect? _visibleBounds(Iterable rectangles) { + final box = context.findRenderObject() as RenderBox; + final viewport = box.localToGlobal(Offset.zero) & box.size; + Rect? bounds; + for (final rectangle in rectangles) { + final visible = rectangle.intersect(viewport); + if (!visible.isEmpty) { + bounds = bounds?.expandToInclude(visible) ?? visible; + } + } + return bounds; + } + + Rect? get _selectionBounds => _visibleBounds([ + for (final fragment in _fragments) + if (fragment.range case final range?) + ...fragment.rectangles(range.startOffset, range.endOffset), + ]); + + ReaderHighlightTarget? _highlightAt(Offset position) { + final highlights = + ReaderInteractionScope.maybeOf(context)?.highlights ?? + const []; + for (final highlight in highlights.reversed) { + final start = highlight.selection.start; + final end = highlight.selection.end; + if (start is! EpubReaderLocator || + end is! EpubReaderLocator || + start.spineIndex != widget.engine.locator.spineIndex || + start.textOffset == null || + end.textOffset == null) { + continue; + } + final rectangles = [ + for (final fragment in _fragments) + ...fragment.rectangles( + start.textOffset! - fragment.widget.fragment.offset, + end.textOffset! - fragment.widget.fragment.offset, + ), + ]; + if (rectangles.any((rectangle) => rectangle.contains(position))) { + final bounds = _visibleBounds(rectangles); + if (bounds != null) return (highlight: highlight, bounds: bounds); + } + } + return null; + } + + ReaderSelection? _selectedPassage() { + int? first; + int? last; + + void visit(Element element) { + if (element is StatefulElement && + element.state is EpubSelectionFragmentState) { + final state = element.state as EpubSelectionFragmentState; + final selection = state.range; + + if (selection != null && selection.startOffset != selection.endOffset) { + final start = + state.widget.fragment.offset + + math.min(selection.startOffset, selection.endOffset); + final end = + state.widget.fragment.offset + + math.max(selection.startOffset, selection.endOffset); + first = first == null + ? start.toInt() + : math.min(first!, start).toInt(); + last = last == null ? end.toInt() : math.max(last!, end).toInt(); + } + } + + element.visitChildElements(visit); + } + + _selectionKey.currentContext?.visitChildElements(visit); + + if (first == null || last == null || _selectedText.isEmpty) { + return null; + } + + final locator = widget.engine.locator; + EpubReaderLocator at(int offset) => EpubReaderLocator( + spineIndex: locator.spineIndex, + textOffset: offset, + localProgression: (offset / math.max(1, widget.engine.contentLength)) + .clamp(0, 1), + totalProgression: locator.totalProgression, + ); + + return ReaderSelection( + text: _selectedText, + start: at(first!), + end: at(last!), + ); + } + + Widget _selectionMenu(BuildContext context, SelectableRegionState state) { + final interactions = ReaderInteractionScope.maybeOf(this.context); + final items = []; + + void add(String label, ValueChanged? action) { + if (_selectedText.isEmpty || action == null) { + return; + } + + items.add( + ContextMenuButtonItem( + label: label, + onPressed: () { + final selection = _selectedPassage(); + _selectionMenuController.remove(); + state.hideToolbar(); + state.clearSelection(); + if (selection != null) action(selection); + }, + ), + ); + } + + add('Annotate', interactions?.onAnnotate); + add('Add note', interactions?.onNote); + items.addAll( + state.contextMenuButtonItems.map( + (item) => ContextMenuButtonItem( + type: item.type, + label: item.label, + onPressed: () { + _selectionMenuController.remove(); + item.onPressed?.call(); + }, + ), + ), + ); + final bounds = _menuBounds; + if (bounds == null || items.isEmpty) return const SizedBox.shrink(); + return ReaderTextToolbar(bounds: bounds, items: items); + } + + void _selectionStatusChanged(SelectableRegionSelectionStatus status) { + final revision = ++_menuRevision; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || revision != _menuRevision) return; + _selectionMenuController.remove(); + final state = _selectionKey.currentState?.selectableRegion; + if (status != SelectableRegionSelectionStatus.finalized || + _selectedText.isEmpty || + state == null) { + return; + } + _menuBounds = _selectionBounds; + _selectionMenuController.show( + context: context, + contextMenuBuilder: (context) => TapRegion( + groupId: SelectableRegion, + child: _selectionMenu(context, state), + ), + ); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } @override void initState() { @@ -410,6 +596,7 @@ final class _EpubViewportState extends State { PaintingBinding.instance.systemFonts.removeListener(_fontsChanged); widget.engine.moveWithinChapter = null; widget.engine.prepareChapter = null; + _selectionMenuController.remove(); _pagesController.dispose(); _scrollController.dispose(); super.dispose(); @@ -640,55 +827,89 @@ final class _EpubViewportState extends State { ), ); } - // Pointer tracking does not claim the gesture arena from text - // selection, links or scrolling. Only a drag past a boundary turns. - final surface = Listener( - onPointerDown: (_) { - _drag = 0; - _startPage = _pageIndex; - _startScroll = _scrollController.hasClients - ? _scrollController.offset - : 0; - }, - onPointerMove: (event) => _drag += - displayPreferences.layoutMode == ReaderLayoutMode.scroll - ? event.delta.dy - : event.delta.dx, - onPointerUp: (_) { - if (_drag.abs() < 70) return; - if (displayPreferences.layoutMode == - ReaderLayoutMode.scroll && - _scrollController.hasClients) { - final p = _scrollController.position; - if (_drag < 0 && - _startScroll >= p.maxScrollExtent - 1) { - _beyondChapter(1); + final surface = ReaderHighlightInteraction( + hitTest: _highlightAt, + isSelecting: () => _selectedText.isNotEmpty, + child: Listener( + onPointerDown: (event) { + _swipe.start(event); + _startPage = _pageIndex; + _startScroll = _scrollController.hasClients + ? _scrollController.offset + : 0; + }, + onPointerMove: _swipe.update, + onPointerCancel: _swipe.cancel, + onPointerUp: (event) { + final offset = _swipe.end(event); + + if (offset == null) { + return; + } + + final scrolling = + displayPreferences.layoutMode == + ReaderLayoutMode.scroll; + final drag = scrolling ? offset.dy : offset.dx; + final crossAxis = scrolling ? offset.dx : offset.dy; + + if (drag.abs() < 70 || drag.abs() <= crossAxis.abs()) { + return; } - if (_drag > 0 && _startScroll <= 1) _beyondChapter(-1); - } else { - if (_drag < 0 && - _startPage == (pages.length / columns).ceil() - 1) { - _beyondChapter(1); + + if (scrolling && _scrollController.hasClients) { + final position = _scrollController.position; + + if (drag < 0 && + _startScroll >= position.maxScrollExtent - 1) { + _beyondChapter(1); + } + + if (drag > 0 && _startScroll <= 1) { + _beyondChapter(-1); + } + } else { + if (drag < 0 && + _startPage == + (pages.length / columns).ceil() - 1) { + _beyondChapter(1); + } + + if (drag > 0 && _startPage == 0) { + _beyondChapter(-1); + } } - if (_drag > 0 && _startPage == 0) _beyondChapter(-1); - } - }, - child: - Localizations.of( - context, - MaterialLocalizations, - ) == - null - ? content - : SelectionArea( - onSelectionChanged: (selection) => + }, + child: + Localizations.of( + context, + MaterialLocalizations, + ) == + null + ? content + : SelectionArea( + key: _selectionKey, + contextMenuBuilder: _selectionMenu, + onSelectionChanged: (selection) { + final selected = + selection?.plainText.isNotEmpty ?? false; + _selectedText = selection?.plainText ?? ''; + if (!selected) { + _menuRevision++; + _menuBounds = null; + _selectionMenuController.remove(); + } + _swipe.selectionChanged(selected); ReaderInteractionScope.maybeOf( context, - )?.onSelectionChanged( - selection?.plainText.isNotEmpty ?? false, - ), - child: content, - ), + )?.onSelectionChanged(selected); + }, + child: _SelectionStatusObserver( + onChanged: _selectionStatusChanged, + child: content, + ), + ), + ), ); return Stack( fit: StackFit.expand, @@ -738,11 +959,23 @@ final class _EpubViewportState extends State { ReaderPreferences preferences, { bool scroll = false, }) { + final highlights = + ReaderInteractionScope.maybeOf(context)?.highlights.where((highlight) { + final start = highlight.selection.start; + return start is EpubReaderLocator && + start.spineIndex == widget.engine.locator.spineIndex; + }).toList() ?? + const []; + final children = [ for (final fragment in page.fragments) Padding( padding: EdgeInsets.only(bottom: fragment.spacing(preferences)), - child: buildEpubFragment(fragment, preferences), + child: buildEpubFragment( + fragment, + preferences, + highlights: highlights, + ), ), ]; return Padding( @@ -776,3 +1009,43 @@ final class _EpubScrollDelegate extends SliverChildBuilderDelegate { double trailingScrollOffset, ) => extent; } + +/// Observes selection completion without inserting a nested selection container. +final class _SelectionStatusObserver extends StatefulWidget { + const _SelectionStatusObserver({ + required this.onChanged, + required this.child, + }); + final ValueChanged onChanged; + final Widget child; + + @override + State<_SelectionStatusObserver> createState() => + _SelectionStatusObserverState(); +} + +final class _SelectionStatusObserverState + extends State<_SelectionStatusObserver> { + ValueListenable? _status; + + void _changed() => widget.onChanged(_status!.value); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final status = SelectableRegionSelectionStatusScope.maybeOf(context); + if (identical(status, _status)) return; + _status?.removeListener(_changed); + _status = status; + _status?.addListener(_changed); + } + + @override + void dispose() { + _status?.removeListener(_changed); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/src/engine/pdf/pdf_facade.dart b/lib/src/engine/pdf/pdf_facade.dart index 3af12c9..443b4ef 100644 --- a/lib/src/engine/pdf/pdf_facade.dart +++ b/lib/src/engine/pdf/pdf_facade.dart @@ -3,11 +3,16 @@ import 'dart:math'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; import '../../domain/reader_types.dart'; +import '../../domain/reader_selection.dart'; +import '../../domain/reader_locator.dart'; import '../../presentation/reader_interaction_scope.dart'; +import '../../presentation/reader_swipe_tracker.dart'; +import '../../presentation/reader_highlight_interaction.dart'; +import 'pdf_selection_toolbar.dart'; enum PdfFacadeError { invalid, encrypted } @@ -182,8 +187,8 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { Object? _viewKey; int _viewerRevision = 0; DateTime? _lastWheelTurn; - Offset? _touchStart; - int _touchPointers = 0; + final _swipe = ReaderSwipeTracker(); + bool _textSelected = false; @override final int pageCount; @@ -266,6 +271,58 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { ); } + Future _saveSelection( + PdfTextSelectionDelegate delegate, + ValueChanged action, + ) async { + final ranges = await delegate.getSelectedTextRanges(); + + if (_disposed || ranges.isEmpty) { + return; + } + + final rectangles = []; + + for (final range in ranges) { + final page = _controller.document.pages[range.pageNumber - 1]; + + for (final fragment in range.enumerateFragmentBoundingRects()) { + final bounds = fragment.bounds.toRect(page: page); + final rect = Rect.fromLTRB( + (bounds.left / page.width).clamp(0, 1), + (bounds.top / page.height).clamp(0, 1), + (bounds.right / page.width).clamp(0, 1), + (bounds.bottom / page.height).clamp(0, 1), + ); + if (rect.isEmpty) continue; + rectangles.add( + ReaderSelectionRect(pageIndex: range.pageNumber - 1, rect: rect), + ); + } + } + + if (rectangles.isEmpty) return; + + PdfReaderLocator at(int page, double offset) => PdfReaderLocator( + pageIndex: page, + pageOffset: offset, + totalProgression: pageCount <= 1 ? 0 : page / (pageCount - 1), + ); + + final selection = ReaderSelection( + text: ranges.map((range) => range.text).join('\n'), + start: at(ranges.first.pageNumber - 1, rectangles.first.rect.top), + end: at(ranges.last.pageNumber - 1, rectangles.last.rect.bottom), + rectangles: rectangles, + ); + + await delegate.clearTextSelection(); + + if (_disposed) return; + + action(selection); + } + @override Widget buildViewport(PdfViewportConfiguration configuration) { _configuration = configuration; @@ -321,23 +378,21 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { }); WidgetsBinding.instance.scheduleFrame(); }, - onPointerDown: (event) { - if (event.kind != PointerDeviceKind.touch) return; - _touchPointers++; - _touchStart = _touchPointers == 1 ? event.position : null; - }, + onPointerDown: _swipe.start, + onPointerMove: _swipe.update, + onPointerCancel: _swipe.cancel, onPointerUp: (event) { - if (event.kind != PointerDeviceKind.touch) return; - final start = _touchStart; - _touchPointers = max(0, _touchPointers - 1); - _touchStart = null; + final offset = _swipe.end(event); + if (configuration.layoutMode == ReaderLayoutMode.paginated && - start != null && + offset != null && !_restoring && _atFit && - (event.position.dx - start.dx).abs() > 70) { - final direction = event.position.dx < start.dx ? 1 : -1; + offset.dx.abs() > 70 && + offset.dx.abs() > offset.dy.abs()) { + final direction = offset.dx < 0 ? 1 : -1; final turn = ReaderInteractionScope.maybeOf(context)?.onTurn; + if (turn != null) { turn(direction); } else { @@ -345,35 +400,102 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { } } }, - onPointerCancel: (_) { - _touchPointers = 0; - _touchStart = null; - }, - child: PdfViewer( - _documentRef, - key: ValueKey(key), - controller: _controller, - initialPageNumber: _pageIndex + 1, - params: buildPdfViewerParams( - configuration, - viewportSize: size, - onTextSelectionChange: (selection) => - ReaderInteractionScope.maybeOf( - context, - )?.onSelectionChanged(selection.hasSelectedText), - currentPageIndex: () => _pageIndex, - onViewerReady: (_, _) async { - if (_disposed || revision != _viewerRevision) return; - try { - await _restorePage(); - } finally { - if (!_disposed && revision == _viewerRevision) { - _restoring = false; - _positionChanged(); - } - } - }, - onPageChanged: (_) => _positionChanged(), + child: ReaderHighlightInteraction( + isSelecting: () => _textSelected, + hitTest: (position) { + if (!_controller.isReady) return null; + final documentPosition = _controller.globalToDocument(position); + if (documentPosition == null) return null; + final target = pdfHighlightAt( + documentPosition, + _controller.layout.pageLayouts, + ReaderInteractionScope.maybeOf(context)?.highlights ?? + const [], + ); + if (target == null) return null; + final visible = target.bounds.intersect( + _controller.visibleRect, + ); + final topLeft = _controller.documentToGlobal(visible.topLeft); + final bottomRight = _controller.documentToGlobal( + visible.bottomRight, + ); + if (visible.isEmpty || topLeft == null || bottomRight == null) { + return null; + } + return ( + highlight: target.highlight, + bounds: Rect.fromPoints(topLeft, bottomRight), + ); + }, + child: PdfSelectionToolbar( + controller: _controller, + saveSelection: _saveSelection, + child: PdfViewer( + _documentRef, + key: ValueKey(key), + controller: _controller, + initialPageNumber: _pageIndex + 1, + params: buildPdfViewerParams( + configuration, + viewportSize: size, + customizeContextMenuItems: (params, items) { + if (!params.textSelectionDelegate.hasSelectedText || + !params.textSelectionDelegate.isCopyAllowed) { + return; + } + + final interactions = ReaderInteractionScope.maybeOf( + context, + ); + + for (final entry in [ + ('Annotate', interactions?.onAnnotate), + ('Add note', interactions?.onNote), + ]) { + final action = entry.$2; + + if (action != null) { + items.add( + ContextMenuButtonItem( + label: entry.$1, + onPressed: () { + params.dismissContextMenu(); + _saveSelection( + params.textSelectionDelegate, + action, + ); + }, + ), + ); + } + } + }, + highlights: + ReaderInteractionScope.maybeOf(context)?.highlights ?? + const [], + onTextSelectionChange: (selection) { + _textSelected = selection.hasSelectedText; + _swipe.selectionChanged(selection.hasSelectedText); + ReaderInteractionScope.maybeOf( + context, + )?.onSelectionChanged(selection.hasSelectedText); + }, + currentPageIndex: () => _pageIndex, + onViewerReady: (_, _) async { + if (_disposed || revision != _viewerRevision) return; + try { + await _restorePage(); + } finally { + if (!_disposed && revision == _viewerRevision) { + _restoring = false; + _positionChanged(); + } + } + }, + onPageChanged: (_) => _positionChanged(), + ), + ), ), ), ), @@ -446,6 +568,31 @@ final class _PdfrxFacade implements PdfFacade, DisposablePdfFacade { return (contentVisible: contentVisible, pages: qualified); } +ReaderHighlightTarget? pdfHighlightAt( + Offset position, + List pages, + List highlights, +) { + for (final highlight in highlights.reversed) { + for (final rectangle in highlight.selection.rectangles) { + final pageIndex = rectangle.pageIndex; + if (pageIndex < 0 || pageIndex >= pages.length) continue; + final page = pages[pageIndex]; + final rect = rectangle.rect; + final bounds = Rect.fromLTRB( + page.left + rect.left * page.width, + page.top + rect.top * page.height, + page.left + rect.right * page.width, + page.top + rect.bottom * page.height, + ); + if (bounds.contains(position)) { + return (highlight: highlight, bounds: bounds); + } + } + } + return null; +} + PdfViewerParams buildPdfViewerParams( PdfViewportConfiguration configuration, { PdfViewerReadyCallback? onViewerReady, @@ -453,9 +600,34 @@ PdfViewerParams buildPdfViewerParams( ValueChanged? onPageChanged, Size viewportSize = const Size(800, 600), int Function()? currentPageIndex, + PdfViewerContextMenuUpdateMenuItemsFunction? customizeContextMenuItems, + List highlights = const [], }) { final paginated = configuration.layoutMode == ReaderLayoutMode.paginated; return PdfViewerParams( + customizeContextMenuItems: customizeContextMenuItems, + pagePaintCallbacks: [ + (canvas, pageRect, page) { + for (final highlight in highlights) { + for (final rectangle in highlight.selection.rectangles) { + if (rectangle.pageIndex != page.pageNumber - 1) { + continue; + } + + final rect = rectangle.rect; + canvas.drawRect( + Rect.fromLTRB( + pageRect.left + rect.left * pageRect.width, + pageRect.top + rect.top * pageRect.height, + pageRect.left + rect.right * pageRect.width, + pageRect.top + rect.bottom * pageRect.height, + ), + Paint()..color = highlight.color.withValues(alpha: .35), + ); + } + } + }, + ], textSelectionParams: PdfTextSelectionParams( onTextSelectionChange: onTextSelectionChange, ), diff --git a/lib/src/engine/pdf/pdf_selection_toolbar.dart b/lib/src/engine/pdf/pdf_selection_toolbar.dart new file mode 100644 index 0000000..0b40c4f --- /dev/null +++ b/lib/src/engine/pdf/pdf_selection_toolbar.dart @@ -0,0 +1,189 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pdfrx/pdfrx.dart'; + +import '../../domain/reader_selection.dart'; +import '../../presentation/reader_interaction_scope.dart'; +import '../../presentation/reader_text_toolbar.dart'; + +/// pdfrx only finalizes its automatic menu for selection handles, not mouse drags. +final class PdfSelectionToolbar extends StatefulWidget { + const PdfSelectionToolbar({ + required this.controller, + required this.saveSelection, + required this.child, + super.key, + }); + + final PdfViewerController controller; + final Future Function( + PdfTextSelectionDelegate selection, + ValueChanged action, + ) + saveSelection; + final Widget child; + + @override + State createState() => _PdfSelectionToolbarState(); +} + +final class _PdfSelectionToolbarState extends State { + final _menu = ContextMenuController(); + int _revision = 0; + int? _pointer; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_dismiss); + } + + @override + void didUpdateWidget(PdfSelectionToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_dismiss); + widget.controller.addListener(_dismiss); + _dismiss(); + } + } + + void _dismiss() { + _revision++; + _menu.remove(); + } + + void _scheduleMenu() { + final revision = ++_revision; + WidgetsBinding.instance.addPostFrameCallback((_) => _show(revision)); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + Future _show(int revision) async { + final controller = widget.controller; + + if (!mounted || revision != _revision || !controller.isReady) return; + + final selection = controller.textSelectionDelegate; + + if (!selection.hasSelectedText || !selection.isCopyAllowed) return; + + final ranges = await selection.getSelectedTextRanges(); + + if (!mounted || revision != _revision || !controller.isReady) return; + + final viewport = context.findRenderObject() as RenderBox?; + + if (viewport == null || !viewport.hasSize) return; + + final viewportBounds = viewport.localToGlobal(Offset.zero) & viewport.size; + Rect? bounds; + + for (final range in ranges) { + final page = controller.document.pages[range.pageNumber - 1]; + final pageRect = controller.layout.pageLayouts[range.pageNumber - 1]; + + for (final fragment in range.enumerateFragmentBoundingRects()) { + final rect = fragment.bounds.toRectInDocument( + page: page, + pageRect: pageRect, + ); + final start = controller.documentToGlobal(rect.topLeft); + final end = controller.documentToGlobal(rect.bottomRight); + + if (start == null || end == null) continue; + + final visible = Rect.fromPoints(start, end).intersect(viewportBounds); + + if (!visible.isEmpty) { + bounds = bounds?.expandToInclude(visible) ?? visible; + } + } + } + + if (bounds == null) return; + + final interactions = ReaderInteractionScope.maybeOf(context); + final items = []; + + for (final (label, action) in [ + ('Annotate', interactions?.onAnnotate), + ('Add note', interactions?.onNote), + ]) { + if (action != null) { + items.add( + ContextMenuButtonItem( + label: label, + onPressed: () { + _dismiss(); + widget.saveSelection(selection, action); + }, + ), + ); + } + } + + items.add( + ContextMenuButtonItem( + type: ContextMenuButtonType.copy, + onPressed: () async { + _dismiss(); + await selection.copyTextSelection(); + await selection.clearTextSelection(); + }, + ), + ); + + final menuBounds = bounds; + _menu.show( + context: context, + contextMenuBuilder: (_) => TapRegion( + onTapOutside: (_) => _dismiss(), + child: Focus( + autofocus: true, + onKeyEvent: (_, event) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + _dismiss(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: ReaderTextToolbar(bounds: menuBounds, items: items), + ), + ), + ); + } + + @override + void dispose() { + widget.controller.removeListener(_dismiss); + _dismiss(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Listener( + onPointerDown: (event) { + _dismiss(); + _pointer = + event.kind == PointerDeviceKind.mouse && + event.buttons == kPrimaryButton + ? event.pointer + : null; + }, + onPointerUp: (event) { + if (event.pointer == _pointer) { + _pointer = null; + _scheduleMenu(); + } + }, + onPointerCancel: (_) { + _pointer = null; + _dismiss(); + }, + onPointerSignal: (_) => _dismiss(), + child: widget.child, + ); +} diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index d44c75d..e6fac19 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -23,6 +23,7 @@ import 'reader_contents_panel.dart'; import 'reader_settings_panel.dart'; import 'reader_theme_data.dart'; import 'reader_ui_builders.dart'; +import '../domain/reader_selection.dart'; enum _ReaderPanel { none, tableOfContents, settings } @@ -38,6 +39,11 @@ final class PapyrusReader extends StatefulWidget { this.onLocatorChanged, this.onPreferencesChanged, this.onBack, + this.onAnnotate, + this.onNote, + this.onEditHighlight, + this.onRemoveHighlight, + this.highlights = const [], this.builders = const ReaderUiBuilders(), super.key, }) : assert( @@ -55,6 +61,11 @@ final class PapyrusReader extends StatefulWidget { final ReaderLocatorChanged? onLocatorChanged; final ReaderPreferencesChanged? onPreferencesChanged; final VoidCallback? onBack; + final ValueChanged? onAnnotate; + final ValueChanged? onNote; + final ValueChanged? onEditHighlight; + final ValueChanged? onRemoveHighlight; + final List highlights; final ReaderUiBuilders builders; @override @@ -66,7 +77,7 @@ final class _PapyrusReaderState extends State { late bool _ownsController; ReaderSnapshot? _observedSnapshot; _ReaderPanel _panel = _ReaderPanel.none; - bool _controlsVisible = true; + bool _controlsVisible = false; double? _dragProgress; int _loadGeneration = 0; bool _loadedDependencies = false; @@ -132,7 +143,7 @@ final class _PapyrusReaderState extends State { if (controllerChanged || documentChanged) { _panel = _ReaderPanel.none; - _controlsVisible = true; + _controlsVisible = false; _dragProgress = null; _loadDocument(); } @@ -352,10 +363,7 @@ final class _PapyrusReaderState extends State { Widget build(BuildContext context) { _scheduleActivityReport(); final snapshot = _controller.snapshot; - final ambient = Theme.of(context); - final readerTheme = snapshot.status == ReaderStatus.ready - ? buildReaderMaterialTheme(ambient, snapshot.preferences.brightness) - : ambient; + final readerTheme = Theme.of(context); final theme = widget.theme ?? ReaderThemeData.fromTheme(readerTheme); return Theme( data: readerTheme, @@ -523,25 +531,22 @@ final class _PapyrusReaderState extends State { return LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= theme.compactBreakpoint; - final toolbar = widget.builders.toolbar?.call( - context, - ReaderToolbarContext( - document: widget.document, - snapshot: snapshot, - onBack: widget.onBack, - isBusy: _isCommandBusy, - toggleControls: _toggleControls, - controlsVisible: _controlsVisible, - openTableOfContents: () => _openPanel( - context, - _ReaderPanel.tableOfContents, - isWide, - theme, - ), - openSettings: () => - _openPanel(context, _ReaderPanel.settings, isWide, theme), - ), + final toolbarContext = ReaderToolbarContext( + document: widget.document, + snapshot: snapshot, + onBack: widget.onBack, + isBusy: _isCommandBusy, + toggleControls: _toggleControls, + controlsVisible: _controlsVisible, + openTableOfContents: () => + _openPanel(context, _ReaderPanel.tableOfContents, isWide, theme), + openSettings: () => + _openPanel(context, _ReaderPanel.settings, isWide, theme), ); + final toolbar = widget.builders.toolbar?.call(context, toolbarContext); + final actions = + widget.builders.toolbarActions?.call(context, toolbarContext) ?? + const []; return CallbackShortcuts( bindings: _keyBindings, @@ -590,6 +595,9 @@ final class _PapyrusReaderState extends State { ), ), ), + border: Border( + top: BorderSide(color: theme.dividerColor), + ), ), ), ), @@ -608,6 +616,7 @@ final class _PapyrusReaderState extends State { toolbar ?? ReaderToolbar( document: widget.document, + actions: actions, onBack: widget.onBack, onTableOfContents: () => _openPanel( context, @@ -634,6 +643,9 @@ final class _PapyrusReaderState extends State { ), ], ), + border: Border( + bottom: BorderSide(color: theme.dividerColor), + ), ), ), ), @@ -673,9 +685,6 @@ final class _PapyrusReaderState extends State { start: BorderSide( color: theme.dividerColor, ), - top: BorderSide( - color: theme.dividerColor, - ), ), ), child: _buildWidePanel( @@ -722,8 +731,16 @@ final class _PapyrusReaderState extends State { child: child, ); - Widget _overlayBar(ReaderThemeData theme, Widget child) => - Material(color: theme.chromeColor, elevation: 0, child: child); + Widget _overlayBar(ReaderThemeData theme, Widget child, {Border? border}) => + Material( + color: theme.chromeColor, + elevation: 0, + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration(border: border), + child: child, + ), + ); Widget _readingSurface( BuildContext context, @@ -742,9 +759,22 @@ final class _PapyrusReaderState extends State { }, child: ReaderSurface( enabled: _panel == _ReaderPanel.none && _compactPanelRoute == null, + onAnnotate: widget.onAnnotate, + onNote: widget.onNote, + onEditHighlight: widget.onEditHighlight, + onRemoveHighlight: widget.onRemoveHighlight, + highlights: widget.highlights, onToggle: _toggleControls, onTurn: (direction) => direction > 0 ? _goNext() : _goPrevious(), - child: _buildViewport(context, snapshot), + child: Theme( + data: buildReaderMaterialTheme( + Theme.of(context), + snapshot.preferences.brightness, + ), + child: Builder( + builder: (context) => _buildViewport(context, snapshot), + ), + ), ), ); } @@ -886,13 +916,7 @@ final class _PapyrusReaderState extends State { ), builder: (sheetContext) { Widget buildPanel() { - // Captured route themes are a snapshot. Appearance can change while - // this sheet is open, so use the current reader theme on every update. - final preferences = _controller.preferences; - final currentTheme = buildReaderMaterialTheme( - Theme.of(this.context), - preferences.brightness, - ); + final currentTheme = Theme.of(this.context); final colors = currentTheme.colorScheme; final panelTheme = widget.theme ?? ReaderThemeData.fromTheme(currentTheme); @@ -991,6 +1015,29 @@ final class _PapyrusReaderState extends State { controller: _controller, close: close, isBusy: _isCommandBusy, + showHeader: showHeader, + navigate: (locator) { + close(); + _enqueueCommand( + (controller) => controller.goTo(locator), + context: 'while navigating to a saved location', + ); + }, + buildContents: (context) => ReaderContentsPanel( + showHeader: false, + scrollController: scrollController, + snapshot: snapshot, + close: close, + theme: theme, + enabled: !_isCommandBusy, + onNavigate: (locator) { + close(); + _enqueueCommand( + (controller) => controller.goTo(locator), + context: 'while navigating from contents', + ); + }, + ), ); return switch (panel) { diff --git a/lib/src/presentation/reader_controls.dart b/lib/src/presentation/reader_controls.dart index 096683b..024383d 100644 --- a/lib/src/presentation/reader_controls.dart +++ b/lib/src/presentation/reader_controls.dart @@ -77,6 +77,7 @@ final class ReaderToolbar extends StatelessWidget { required this.settingsFocusNode, required this.theme, required this.isWide, + this.actions = const [], }); final ReaderDocument document; @@ -87,6 +88,7 @@ final class ReaderToolbar extends StatelessWidget { final FocusNode settingsFocusNode; final ReaderThemeData theme; final bool isWide; + final List actions; @override Widget build(BuildContext context) { @@ -104,6 +106,7 @@ final class ReaderToolbar extends StatelessWidget { scrolledUnderElevation: 0, leading: onBack == null ? null : BackButton(onPressed: onBack), actions: [ + ...actions, ReaderIconButton( key: const ValueKey('reader-toc-button'), focusNode: tocFocusNode, diff --git a/lib/src/presentation/reader_highlight_interaction.dart b/lib/src/presentation/reader_highlight_interaction.dart new file mode 100644 index 0000000..6b934d2 --- /dev/null +++ b/lib/src/presentation/reader_highlight_interaction.dart @@ -0,0 +1,143 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../domain/reader_selection.dart'; +import 'reader_interaction_scope.dart'; +import 'reader_text_toolbar.dart'; + +typedef ReaderHighlightTarget = ({ReaderHighlight highlight, Rect bounds}); + +/// Observes short taps without taking drags or long presses away from selection. +final class ReaderHighlightInteraction extends StatefulWidget { + const ReaderHighlightInteraction({ + required this.hitTest, + required this.isSelecting, + required this.child, + super.key, + }); + + final ReaderHighlightTarget? Function(Offset position) hitTest; + final bool Function() isSelecting; + final Widget child; + + @override + State createState() => + _ReaderHighlightInteractionState(); +} + +final class _ReaderHighlightInteractionState + extends State { + final _menu = ContextMenuController(); + final _pointers = {}; + PointerDownEvent? _start; + ReaderHighlightTarget? _target; + + void _show(ReaderHighlightTarget target) { + final interactions = ReaderInteractionScope.maybeOf(context); + final items = []; + + for (final (label, action) in [ + ('Edit', interactions?.onEditHighlight), + ('Remove', interactions?.onRemoveHighlight), + ]) { + if (action != null) { + items.add( + ContextMenuButtonItem( + label: label, + onPressed: () { + _menu.remove(); + action(target.highlight); + }, + ), + ); + } + } + + items.add( + ContextMenuButtonItem( + type: ContextMenuButtonType.copy, + onPressed: () { + _menu.remove(); + Clipboard.setData( + ClipboardData(text: target.highlight.selection.text), + ); + }, + ), + ); + + _menu.show( + context: context, + contextMenuBuilder: (context) => TapRegion( + groupId: SelectableRegion, + onTapOutside: (_) => _menu.remove(), + child: Focus( + autofocus: true, + onKeyEvent: (_, event) { + if (event.logicalKey == LogicalKeyboardKey.escape) { + _menu.remove(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: ReaderTextToolbar(bounds: target.bounds, items: items), + ), + ), + ); + } + + @override + void dispose() { + _menu.remove(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Listener( + onPointerSignal: (_) => _menu.remove(), + onPointerDown: (event) { + _menu.remove(); + _pointers.add(event.pointer); + _target = null; + _start = event; + + if (_pointers.length != 1 || + event.buttons != kPrimaryButton || + widget.isSelecting()) { + return; + } + + _target = widget.hitTest(event.position); + + if (_target != null) { + ReaderInteractionScope.maybeOf(context)?.suppressTap(); + } + }, + onPointerMove: (event) { + if (_start != null && + (event.position - _start!.position).distance > kTouchSlop) { + _target = null; + } + }, + onPointerCancel: (event) { + _pointers.remove(event.pointer); + _target = null; + }, + onPointerUp: (event) { + _pointers.remove(event.pointer); + final target = _target; + final start = _start; + _target = null; + + if (target != null && + start != null && + event.pointer == start.pointer && + _pointers.isEmpty && + !widget.isSelecting() && + event.timeStamp - start.timeStamp < kLongPressTimeout) { + _show(target); + } + }, + child: widget.child, + ); +} diff --git a/lib/src/presentation/reader_interaction_scope.dart b/lib/src/presentation/reader_interaction_scope.dart index 9e840fc..512f147 100644 --- a/lib/src/presentation/reader_interaction_scope.dart +++ b/lib/src/presentation/reader_interaction_scope.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import '../domain/reader_selection.dart'; /// Routes viewport boundary gestures through the shell's command queue. final class ReaderInteractionScope extends InheritedWidget { @@ -6,6 +7,11 @@ final class ReaderInteractionScope extends InheritedWidget { required this.onTurn, required this.onSelectionChanged, required this.suppressTap, + this.onAnnotate, + this.onNote, + this.onEditHighlight, + this.onRemoveHighlight, + this.highlights = const [], required super.child, super.key, }); @@ -13,11 +19,21 @@ final class ReaderInteractionScope extends InheritedWidget { final ValueChanged onTurn; final ValueChanged onSelectionChanged; final VoidCallback suppressTap; + final ValueChanged? onAnnotate; + final ValueChanged? onNote; + final ValueChanged? onEditHighlight; + final ValueChanged? onRemoveHighlight; + final List highlights; static ReaderInteractionScope? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType(); @override bool updateShouldNotify(ReaderInteractionScope oldWidget) => - onTurn != oldWidget.onTurn; + onTurn != oldWidget.onTurn || + highlights != oldWidget.highlights || + onAnnotate != oldWidget.onAnnotate || + onNote != oldWidget.onNote || + onEditHighlight != oldWidget.onEditHighlight || + onRemoveHighlight != oldWidget.onRemoveHighlight; } diff --git a/lib/src/presentation/reader_surface.dart b/lib/src/presentation/reader_surface.dart index 514680d..e0da3ff 100644 --- a/lib/src/presentation/reader_surface.dart +++ b/lib/src/presentation/reader_surface.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'reader_interaction_scope.dart'; +import '../domain/reader_selection.dart'; /// Observe taps without competing with selection or the viewport's drag arena. final class ReaderSurface extends StatefulWidget { @@ -12,6 +13,11 @@ final class ReaderSurface extends StatefulWidget { required this.onToggle, required this.onTurn, required this.child, + this.onAnnotate, + this.onNote, + this.onEditHighlight, + this.onRemoveHighlight, + this.highlights = const [], super.key, }); @@ -19,6 +25,11 @@ final class ReaderSurface extends StatefulWidget { final VoidCallback onToggle; final ValueChanged onTurn; final Widget child; + final ValueChanged? onAnnotate; + final ValueChanged? onNote; + final ValueChanged? onEditHighlight; + final ValueChanged? onRemoveHighlight; + final List highlights; @override State createState() => _ReaderSurfaceState(); @@ -44,6 +55,11 @@ final class _ReaderSurfaceState extends State { @override Widget build(BuildContext context) => ReaderInteractionScope( onTurn: widget.onTurn, + onAnnotate: widget.onAnnotate, + onNote: widget.onNote, + onEditHighlight: widget.onEditHighlight, + onRemoveHighlight: widget.onRemoveHighlight, + highlights: widget.highlights, onSelectionChanged: (selected) { if (selected || _selection) { _cancelled = true; diff --git a/lib/src/presentation/reader_swipe_tracker.dart b/lib/src/presentation/reader_swipe_tracker.dart new file mode 100644 index 0000000..21b565b --- /dev/null +++ b/lib/src/presentation/reader_swipe_tracker.dart @@ -0,0 +1,71 @@ +import 'package:flutter/gestures.dart'; + +/// Raw boundary gestures must yield to selection, long presses and multi-touch. +final class ReaderSwipeTracker { + final Set _pointers = {}; + PointerDownEvent? _start; + bool _cancelled = false; + bool _dragging = false; + bool _selected = false; + + void selectionChanged(bool selected) { + if (selected || _selected) { + _cancelled = true; + } + + _selected = selected; + } + + void start(PointerDownEvent event) { + _pointers.add(event.pointer); + + if (_pointers.length != 1) { + _cancelled = true; + return; + } + + _start = event; + _dragging = false; + _cancelled = _selected || event.kind != PointerDeviceKind.touch; + } + + void update(PointerMoveEvent event) { + final start = _start; + + if (start == null || _cancelled || event.pointer != start.pointer) { + return; + } + + if (!_dragging && event.timeStamp - start.timeStamp >= kLongPressTimeout) { + _cancelled = true; + return; + } + + if ((event.position - start.position).distance > kTouchSlop) { + _dragging = true; + } + } + + Offset? end(PointerUpEvent event) { + final start = _start; + _pointers.remove(event.pointer); + + if (start == null || event.pointer != start.pointer) { + return null; + } + + _start = null; + + if (_cancelled || _selected || !_dragging || _pointers.isNotEmpty) { + return null; + } + + return event.position - start.position; + } + + void cancel(PointerCancelEvent event) { + _pointers.remove(event.pointer); + _cancelled = true; + _start = null; + } +} diff --git a/lib/src/presentation/reader_text_toolbar.dart b/lib/src/presentation/reader_text_toolbar.dart new file mode 100644 index 0000000..65f09f5 --- /dev/null +++ b/lib/src/presentation/reader_text_toolbar.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +/// Positions actions against the actual glyphs rather than a secondary-click anchor. +final class ReaderTextToolbar extends StatelessWidget { + const ReaderTextToolbar({ + required this.bounds, + required this.items, + super.key, + }); + + final Rect bounds; + final List items; + + @override + Widget build(BuildContext context) => TextSelectionToolbar( + anchorAbove: bounds.topCenter, + anchorBelow: bounds.bottomCenter, + children: [ + for (var index = 0; index < items.length; index++) + TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding( + index, + items.length, + ), + onPressed: items[index].onPressed, + child: Text( + AdaptiveTextSelectionToolbar.getButtonLabel(context, items[index]), + ), + ), + ], + ); +} diff --git a/lib/src/presentation/reader_theme_data.dart b/lib/src/presentation/reader_theme_data.dart index 9c88dbc..1dea375 100644 --- a/lib/src/presentation/reader_theme_data.dart +++ b/lib/src/presentation/reader_theme_data.dart @@ -23,9 +23,9 @@ final class ReaderThemeData { final colors = theme.colorScheme; return ReaderThemeData( - chromeColor: colors.surfaceContainer, + chromeColor: colors.surface, surfaceColor: colors.surface, - panelColor: colors.surfaceContainerHigh, + panelColor: colors.surface, accentColor: colors.primary, progressColor: colors.primary, handleColor: colors.primary, diff --git a/lib/src/presentation/reader_ui_builders.dart b/lib/src/presentation/reader_ui_builders.dart index 40fc5ca..b200fbe 100644 --- a/lib/src/presentation/reader_ui_builders.dart +++ b/lib/src/presentation/reader_ui_builders.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import '../controller/reader_controller.dart'; import '../domain/reader_document.dart'; +import '../domain/reader_locator.dart'; import '../domain/reader_snapshot.dart'; typedef ReaderToolbarBuilder = @@ -50,7 +51,7 @@ final class ReaderToolbarContext { this.onBack, this.isBusy = false, this.toggleControls, - this.controlsVisible = true, + this.controlsVisible = false, }); final ReaderDocument document; @@ -71,8 +72,14 @@ final class ReaderPanelContext { required this.controller, required this.close, this.isBusy = false, + this.showHeader = true, + this.buildContents, + this.navigate, }); + final bool showHeader; + final WidgetBuilder? buildContents; + final ValueChanged? navigate; final ReaderSnapshot snapshot; final ReaderController controller; final VoidCallback close; @@ -108,6 +115,7 @@ final class ReaderViewportContext { final class ReaderUiBuilders { const ReaderUiBuilders({ this.toolbar, + this.toolbarActions, this.compactPanelRoute, this.tableOfContents, this.settings, @@ -118,6 +126,8 @@ final class ReaderUiBuilders { }); final ReaderToolbarBuilder? toolbar; + final List Function(BuildContext context, ReaderToolbarContext state)? + toolbarActions; /// Overrides compact panel presentation; wide side panels are unchanged. final ReaderPanelRouteBuilder? compactPanelRoute; diff --git a/test/domain/reader_preferences_test.dart b/test/domain/reader_preferences_test.dart index 49f4828..770d145 100644 --- a/test/domain/reader_preferences_test.dart +++ b/test/domain/reader_preferences_test.dart @@ -12,7 +12,7 @@ void main() { expect(preferences.pageMargins, const EdgeInsets.all(24)); expect(preferences.brightness, Brightness.light); expect(preferences.layoutMode, ReaderLayoutMode.paginated); - expect(preferences.columnMode, ReaderColumnMode.automatic); + expect(preferences.columnMode, ReaderColumnMode.single); }); test('copyWith changes selected values and preserves the rest', () { diff --git a/test/engine/epub_rich_layout_test.dart b/test/engine/epub_rich_layout_test.dart index 6db4d59..e9b86e3 100644 --- a/test/engine/epub_rich_layout_test.dart +++ b/test/engine/epub_rich_layout_test.dart @@ -5,6 +5,52 @@ import 'package:papyrus_reader/src/engine/epub/epub_rich_layout.dart'; import 'package:papyrus_reader/src/engine/epub/worker/epub_content.dart'; void main() { + test('saved text offsets highlight the same text across page fragments', () { + final block = EpubContentBlock({ + 'kind': 'p', + 'offset': 20, + 'length': 16, + 'runs': [ + {'text': 'Repeated passage'}, + ], + }); + final selection = ReaderSelection( + text: 'passage', + start: EpubReaderLocator( + spineIndex: 0, + textOffset: 29, + localProgression: 0, + totalProgression: 0, + ), + end: EpubReaderLocator( + spineIndex: 0, + textOffset: 36, + localProgression: 1, + totalProgression: 1, + ), + ); + final highlight = ReaderHighlight( + id: 'saved', + selection: ReaderSelection.fromJson(selection.toJson()), + color: const Color(0xffffff00), + ); + final full = block.span(const ReaderPreferences(), highlights: [highlight]); + expect(full.toPlainText(), 'Repeated passage'); + expect((full.children!.last as TextSpan).text, 'passage'); + expect((full.children!.last as TextSpan).style!.backgroundColor, isNotNull); + final fragment = block.span( + const ReaderPreferences(fontSize: 28), + start: 12, + end: 16, + highlights: [highlight], + ); + expect(fragment.toPlainText(), 'sage'); + expect( + (fragment.children!.single as TextSpan).style!.backgroundColor, + isNotNull, + ); + }); + TestWidgetsFlutterBinding.ensureInitialized(); testWidgets('measured fragments match the rendered Material reader text', ( tester, diff --git a/test/engine/pdf_highlight_test.dart b/test/engine/pdf_highlight_test.dart new file mode 100644 index 0000000..c760b21 --- /dev/null +++ b/test/engine/pdf_highlight_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/pdf/pdf_facade.dart'; +import 'package:pdfrx/pdfrx.dart'; + +class _Page extends Fake implements PdfPage { + _Page(this.pageNumber); + @override + final int pageNumber; +} + +class _Canvas extends Fake implements Canvas { + final rectangles = []; + @override + void drawRect(Rect rect, Paint paint) => rectangles.add(rect); +} + +void main() { + test( + 'PDF highlight hits follow page position and choose the topmost overlapping highlight', + () { + final locator = PdfReaderLocator( + pageIndex: 1, + pageOffset: .2, + totalProgression: .5, + ); + final highlight = ReaderHighlight( + id: 'saved', + color: const Color(0xffffff00), + selection: ReaderSelection( + text: 'Saved passage', + start: locator, + end: locator, + rectangles: const [ + ReaderSelectionRect( + pageIndex: 1, + rect: Rect.fromLTRB(.1, .2, .8, .3), + ), + ], + ), + ); + final pages = [ + const Rect.fromLTWH(0, 0, 200, 400), + const Rect.fromLTWH(220, 0, 200, 400), + ]; + expect( + pdfHighlightAt(const Offset(250, 100), pages, [ + highlight, + ])?.highlight.id, + 'saved', + ); + expect( + pdfHighlightAt(const Offset(250, 100), pages, [highlight])?.bounds, + const Rect.fromLTRB(240, 80, 380, 120), + ); + expect(pdfHighlightAt(const Offset(30, 100), pages, [highlight]), isNull); + expect( + pdfHighlightAt(const Offset(250, 200), pages, [highlight]), + isNull, + ); + final overlap = ReaderHighlight( + id: 'newer', + color: const Color(0xff00ff00), + selection: highlight.selection, + ); + expect( + pdfHighlightAt(const Offset(250, 100), pages, [ + highlight, + overlap, + ])?.highlight.id, + 'newer', + ); + }, + ); + + test( + 'PDF highlights scale with the page and never bleed into other pages', + () { + final locator = PdfReaderLocator( + pageIndex: 2, + pageOffset: .2, + totalProgression: .4, + ); + final selection = ReaderSelection( + text: 'Selected PDF text', + start: locator, + end: locator, + rectangles: const [ + ReaderSelectionRect( + pageIndex: 2, + rect: Rect.fromLTRB(.1, .2, .8, .3), + ), + ], + ); + final restored = ReaderSelection.fromJson(selection.toJson()); + final params = buildPdfViewerParams( + PdfViewportConfiguration( + pageIndex: 2, + facingPages: false, + backgroundColor: const Color(0xffffffff), + brightness: Brightness.light, + layoutMode: ReaderLayoutMode.scroll, + onPageChanged: (_) {}, + ), + highlights: [ + ReaderHighlight( + id: 'saved', + selection: restored, + color: const Color(0xffffff00), + ), + ], + ); + final canvas = _Canvas(); + final paint = params.pagePaintCallbacks!.single; + paint(canvas, const Rect.fromLTWH(10, 20, 200, 400), _Page(3)); + expect(canvas.rectangles.single, const Rect.fromLTRB(30, 100, 170, 140)); + paint(canvas, const Rect.fromLTWH(10, 20, 400, 800), _Page(3)); + expect(canvas.rectangles.last, const Rect.fromLTRB(50, 180, 330, 260)); + paint(canvas, const Rect.fromLTWH(10, 20, 400, 800), _Page(4)); + expect(canvas.rectangles, hasLength(2)); + }, + ); +} diff --git a/test/engine/pdf_reader_engine_test.dart b/test/engine/pdf_reader_engine_test.dart index 4fdd17a..22fc858 100644 --- a/test/engine/pdf_reader_engine_test.dart +++ b/test/engine/pdf_reader_engine_test.dart @@ -73,7 +73,12 @@ void main() { final facade = FakePdfFacade(pageCount: 5); final engine = PdfReaderEngine(facadeFactory: (_) async => facade); addTearDown(engine.dispose); - await engine.load(document(), preferences: const ReaderPreferences()); + await engine.load( + document(), + preferences: const ReaderPreferences( + columnMode: ReaderColumnMode.automatic, + ), + ); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, @@ -88,7 +93,10 @@ void main() { await engine.goPrevious(); expect((await engine.currentLocator() as PdfReaderLocator).pageIndex, 2); await engine.updatePreferences( - const ReaderPreferences(layoutMode: ReaderLayoutMode.scroll), + const ReaderPreferences( + layoutMode: ReaderLayoutMode.scroll, + columnMode: ReaderColumnMode.automatic, + ), ); await engine.goNext(); expect((await engine.currentLocator() as PdfReaderLocator).pageIndex, 3); diff --git a/test/presentation/epub_reading_flow_test.dart b/test/presentation/epub_reading_flow_test.dart index 1f2df71..e86132f 100644 --- a/test/presentation/epub_reading_flow_test.dart +++ b/test/presentation/epub_reading_flow_test.dart @@ -34,6 +34,8 @@ void main() { ), ); await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); await pumpReaderCommand(tester, engine.goNext()); await tester.pumpAndSettle(); await pumpReaderCommand(tester, engine.goNext()); diff --git a/test/presentation/epub_selection_test.dart b/test/presentation/epub_selection_test.dart new file mode 100644 index 0000000..d4e5469 --- /dev/null +++ b/test/presentation/epub_selection_test.dart @@ -0,0 +1,371 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/presentation/reader_text_toolbar.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + for (final kind in [PointerDeviceKind.mouse, PointerDeviceKind.touch]) { + testWidgets( + 'selecting text at a chapter boundary does not turn the page ($kind)', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.reset); + final engine = testEpubEngine(); + ReaderSelection? saved; + const words = 'Select these words without leaving this chapter.'; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + platform: kind == PointerDeviceKind.touch + ? TargetPlatform.android + : TargetPlatform.linux, + ), + home: PapyrusReader( + onAnnotate: (selection) => saved = selection, + document: ReaderDocument( + id: 'selection', + format: ReaderFormat.epub, + loadBytes: () async => + syntheticEpub(secondChapterBody: '

$words

'), + ), + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + ), + ), + ); + await tester.pumpAndSettle(); + await pumpReaderCommand(tester, engine.goNext()); + final before = engine.locator; + final paragraph = tester.renderObject( + find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText() == words, + ), + ); + final start = paragraph.localToGlobal( + paragraph + .getBoxesForSelection( + const TextSelection(baseOffset: 1, extentOffset: 2), + ) + .first + .toRect() + .center, + ); + final gesture = await tester.startGesture(start, kind: kind); + + if (kind == PointerDeviceKind.touch) { + await tester.pump( + kLongPressTimeout + const Duration(milliseconds: 100), + ); + } + + await gesture.moveTo( + start + const Offset(160, 0), + timeStamp: const Duration(milliseconds: 700), + ); + await tester.pump(); + await gesture.up(timeStamp: const Duration(milliseconds: 750)); + await tester.pumpAndSettle(); + expect( + paragraph.selections.any((selection) => !selection.isCollapsed), + isTrue, + ); + expect(engine.locator, before); + if (find.text('Annotate').evaluate().isEmpty && + find.byType(TextSelectionToolbar).evaluate().isNotEmpty) { + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + } + expect(find.text('Annotate'), findsOneWidget); + await tester.tap(find.text('Annotate')); + await tester.pumpAndSettle(); + expect(saved, isNotNull); + final first = saved!.start as EpubReaderLocator; + final last = saved!.end as EpubReaderLocator; + expect(first.spineIndex, before.spineIndex); + expect( + words.substring(first.textOffset!, last.textOffset!), + saved!.text, + ); + expect(ReaderSelection.fromJson(saved!.toJson()).start, saved!.start); + expect(find.byTooltip('Reading settings'), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + + for (final (column, longParagraph, backwards) in [ + (0, true, false), + (0, false, false), + (1, true, false), + (1, false, false), + (1, true, true), + (1, false, true), + ]) { + testWidgets( + 'mouse selection opens annotation actions in EPUB column $column, long paragraph $longParagraph, backwards $backwards', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 800); + addTearDown(tester.view.reset); + final engine = testEpubEngine(); + ReaderSelection? saved; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.linux), + home: PapyrusReader( + initialPreferences: const ReaderPreferences( + columnMode: ReaderColumnMode.double, + ), + onAnnotate: (selection) => saved = selection, + document: ReaderDocument( + id: 'columns', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + longChapter: !longParagraph, + firstChapterBody: longParagraph + ? '

${List.filled(1000, 'Paragraph words for selection across columns. ').join()}

' + : null, + ), + ), + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + ), + ), + ); + await tester.pumpAndSettle(); + final before = engine.locator; + final candidates = find.byWidgetPredicate( + (widget) => + widget is RichText && + widget.text.toPlainText().contains('Paragraph'), + ); + final paragraphs = tester.renderObjectList(candidates); + final visible = paragraphs.where((paragraph) { + final point = paragraph.localToGlobal(Offset.zero); + return point.dx >= column * 600 && + point.dx < (column + 1) * 600 && + point.dy < 600; + }); + final paragraph = visible.first; + Offset at(int offset) => paragraph.localToGlobal( + paragraph + .getBoxesForSelection( + TextSelection(baseOffset: offset, extentOffset: offset + 1), + ) + .first + .toRect() + .center, + ); + Future select() async { + final drag = await tester.startGesture( + at(backwards ? 10 : 1), + kind: PointerDeviceKind.mouse, + ); + await drag.moveTo(at(backwards ? 1 : 10)); + await tester.pump(); + await drag.up(); + await tester.pumpAndSettle(); + } + + await select(); + expect(find.text('Annotate'), findsOneWidget); + final bounds = tester + .widget(find.byType(ReaderTextToolbar)) + .bounds; + expect( + bounds.center.dx, + inInclusiveRange(column * 600.0, (column + 1) * 600.0), + ); + expect( + bounds.top, + closeTo( + at(1).dy - + paragraph + .getBoxesForSelection( + const TextSelection(baseOffset: 1, extentOffset: 2), + ) + .first + .toRect() + .height / + 2, + 1, + ), + ); + await tester.tapAt(const Offset(1190, 790)); + await tester.pumpAndSettle(); + expect(find.text('Annotate'), findsNothing); + await select(); + expect( + paragraph.selections.any((selection) => !selection.isCollapsed), + isTrue, + ); + expect(engine.locator, before); + expect(find.text('Annotate'), findsOneWidget); + await tester.tap(find.text('Annotate')); + await tester.pumpAndSettle(); + expect(find.text('Annotate'), findsNothing); + expect(saved, isNotNull); + expect(saved!.text, isNotEmpty); + expect( + (saved!.start as EpubReaderLocator).textOffset, + greaterThanOrEqualTo(column == 1 ? 100 : 0), + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + + for (final kind in [PointerDeviceKind.mouse, PointerDeviceKind.touch]) { + testWidgets( + 'saved highlight offers edit and remove without toggling chrome ($kind)', + (tester) async { + final engine = testEpubEngine(); + const words = 'Select these words without leaving this chapter.'; + final start = EpubReaderLocator( + spineIndex: 1, + textOffset: 7, + localProgression: .1, + totalProgression: .5, + ); + final end = EpubReaderLocator( + spineIndex: 1, + textOffset: 18, + localProgression: .3, + totalProgression: .5, + ); + final highlight = ReaderHighlight( + id: 'saved', + selection: ReaderSelection( + text: 'these words', + start: start, + end: end, + ), + color: Colors.green, + ); + ReaderHighlight? edited; + ReaderHighlight? removed; + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + document: ReaderDocument( + id: 'highlight', + format: ReaderFormat.epub, + loadBytes: () async => + syntheticEpub(secondChapterBody: '

$words

'), + ), + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + highlights: [highlight], + onEditHighlight: (highlight) => edited = highlight, + onRemoveHighlight: (highlight) => removed = highlight, + onAnnotate: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + await pumpReaderCommand(tester, engine.goNext()); + final before = engine.locator; + final paragraph = tester.renderObject( + find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText() == words, + ), + ); + final rect = paragraph + .getBoxesForSelection( + const TextSelection(baseOffset: 9, extentOffset: 10), + ) + .first + .toRect() + .shift(paragraph.localToGlobal(Offset.zero)); + await tester.tapAt(rect.center, kind: kind); + await tester.pumpAndSettle(); + expect(find.text('Edit'), findsOneWidget); + expect(find.text('Remove'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Annotate'), findsNothing); + expect(find.byTooltip('Reading settings'), findsNothing); + expect(engine.locator, before); + final bounds = tester + .widget(find.byType(ReaderTextToolbar)) + .bounds; + expect(bounds.contains(rect.center), isTrue); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + expect(edited?.id, 'saved'); + expect(find.text('Edit'), findsNothing); + await tester.tapAt(rect.center, kind: kind); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + expect(removed?.id, 'saved'); + final drag = await tester.startGesture(rect.center, kind: kind); + if (kind == PointerDeviceKind.touch) { + await tester.pump( + kLongPressTimeout + const Duration(milliseconds: 100), + ); + } + await drag.moveBy(const Offset(120, 0)); + await tester.pump(); + await drag.up(); + await tester.pumpAndSettle(); + expect(find.text('Edit'), findsNothing); + expect(find.text('Annotate'), findsOneWidget); + expect(engine.locator, before); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + + testWidgets( + 'an ordinary touch swipe still advances beyond the last page of a chapter', + (tester) async { + final engine = testEpubEngine(); + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + document: ReaderDocument( + id: 'swipe', + format: ReaderFormat.epub, + loadBytes: () async => + syntheticEpub(firstChapterBody: '

A short chapter.

'), + ), + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.dragFrom(const Offset(500, 400), const Offset(-200, 0)); + await tester.pumpAndSettle(); + expect(engine.locator.spineIndex, 1); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); +} diff --git a/test/presentation/papyrus_reader_test.dart b/test/presentation/papyrus_reader_test.dart index d561a6f..e359408 100644 --- a/test/presentation/papyrus_reader_test.dart +++ b/test/presentation/papyrus_reader_test.dart @@ -60,6 +60,7 @@ void main() { ReaderPreferences? initialPreferences, ReaderThemeData? readerTheme, VoidCallback? onBack, + bool showControls = true, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -101,8 +102,67 @@ void main() { ), ), ); + if (showControls) { + await tester.pump(); + + if (controller.snapshot.status == ReaderStatus.ready) { + await toggleReaderControls(tester); + } + } } + testWidgets( + 'books open with hidden controls and bordered overlays preserve the viewport', + (tester) async { + final controller = controllerFor([_UiReaderEngine()]); + addTearDown(controller.dispose); + await pumpReader( + tester, + document: document('Hidden controls'), + controller: controller, + showControls: false, + ); + await tester.pumpAndSettle(); + final viewport = find.byKey(const ValueKey('reader-viewport')); + final element = tester.element(viewport); + final bounds = tester.getRect(viewport); + final locator = controller.snapshot.locator; + expect(find.byTooltip('Reading settings'), findsNothing); + expect(find.byTooltip('Next'), findsNothing); + await toggleReaderControls(tester); + expect( + find.byTooltip('Reading settings').hitTestable(), + findsOneWidget, + ); + expect(find.byTooltip('Next').hitTestable(), findsOneWidget); + final header = find.descendant( + of: find.byKey(const ValueKey('reader-toolbar')), + matching: find.byType(DecoratedBox), + ); + final headerBorder = + (tester.widget(header.first).decoration + as BoxDecoration) + .border! + as Border; + final footer = find.ancestor( + of: find.byTooltip('Next'), + matching: find.byType(DecoratedBox), + ); + final footerBorder = + (tester.widget(footer.first).decoration + as BoxDecoration) + .border! + as Border; + expect(headerBorder.bottom.style, BorderStyle.solid); + expect(footerBorder.top.style, BorderStyle.solid); + expect(tester.element(viewport), same(element)); + expect(tester.getRect(viewport), bounds); + expect(controller.snapshot.locator, locator); + await toggleReaderControls(tester); + expect(find.byTooltip('Next'), findsNothing); + }, + ); + testWidgets('custom snapshot viewports refresh without losing state', ( tester, ) async { @@ -332,17 +392,18 @@ void main() { await tester.pumpAndSettle(); expect(find.byTooltip('Reading settings'), findsOneWidget); expect(find.byTooltip('Next'), findsOneWidget); - // Opening a different book must restore its exit/navigation controls. + // Opening a different book starts with controls hidden. await toggleReaderControls(tester); await tester.pumpAndSettle(); await pumpReader( tester, document: document('Replacement'), + showControls: false, controller: controllerFor([_UiReaderEngine()]), size: Size(width, 844), ); await tester.pumpAndSettle(); - expect(find.byTooltip('Reading settings'), findsOneWidget); + expect(find.byTooltip('Reading settings'), findsNothing); final replacement = tester .widget(find.byType(PapyrusReader)) .controller!; @@ -475,7 +536,7 @@ void main() { }, ); - testWidgets('all settings dropdown routes follow the reading appearance', ( + testWidgets('all settings dropdown routes follow the app theme', ( tester, ) async { for (final brightness in [Brightness.light, Brightness.dark]) { @@ -497,15 +558,23 @@ void main() { for (final pair in [ ('System', 'Monospace'), ('Paginated', 'Continuous scroll'), - ('Automatic', 'Double'), + ('Single', 'Double'), ]) { await tester.ensureVisible(find.text(pair.$1).first); await tester.tap(find.text(pair.$1).first); await tester.pumpAndSettle(); final option = find.text(pair.$2).last; final menuTheme = Theme.of(tester.element(option)); - expect(menuTheme.brightness, brightness); - expect(menuTheme.canvasColor, menuTheme.colorScheme.surface); + expect( + menuTheme.brightness, + brightness == Brightness.light + ? Brightness.dark + : Brightness.light, + ); + expect( + menuTheme.canvasColor, + Theme.of(tester.element(find.byType(PapyrusReader))).canvasColor, + ); final text = tester.widget( find .descendant(of: option, matching: find.byType(RichText)) @@ -528,7 +597,7 @@ void main() { }); testWidgets( - 'settings text follows reader appearance in opposite app themes', + 'settings text follows the app theme with opposite reading appearances', (tester) async { for (final brightness in [Brightness.dark, Brightness.light]) { final engine = _UiReaderEngine(); @@ -549,18 +618,24 @@ void main() { await tester.pumpAndSettle(); final label = find.text('Page appearance'); final theme = Theme.of(tester.element(label)); - expect(theme.brightness, brightness); + expect( + theme.brightness, + brightness == Brightness.light ? Brightness.dark : Brightness.light, + ); final rich = tester.widget( find.descendant(of: label, matching: find.byType(RichText)), ); - expect(rich.text.style!.color, theme.colorScheme.onSurface); + expect(rich.text.style!.color, theme.textTheme.titleMedium!.color); final dropdown = find.text('Paginated'); final dropdownText = tester.widget( find .descendant(of: dropdown, matching: find.byType(RichText)) .first, ); - expect(dropdownText.text.style!.color, theme.colorScheme.onSurface); + expect( + dropdownText.text.style!.color, + theme.textTheme.titleMedium!.color, + ); for (final chip in tester.widgetList( find.byType(ChoiceChip), )) { @@ -588,7 +663,7 @@ void main() { ); testWidgets( - 'an open mobile panel updates its theme when Night is selected', + 'an open mobile panel retains the app theme when Night is selected', (tester) async { final engine = _UiReaderEngine(); final controller = controllerFor([engine]); @@ -607,11 +682,12 @@ void main() { await tester.pumpAndSettle(); final label = find.text('Page appearance'); final theme = Theme.of(tester.element(label)); - expect(theme.brightness, Brightness.dark); + expect(theme.brightness, Brightness.light); + expect(controller.preferences.brightness, Brightness.dark); final rich = tester.widget( find.descendant(of: label, matching: find.byType(RichText)), ); - expect(rich.text.style!.color, theme.colorScheme.onSurface); + expect(rich.text.style!.color, theme.textTheme.titleMedium!.color); expect(tester.takeException(), isNull); }, ); @@ -644,8 +720,9 @@ void main() { expect(find.byKey(const ValueKey('reader-viewport')), findsOneWidget); expect(find.text('The Loading Reader'), findsNothing); - expect(find.byTooltip('Reading settings'), findsOneWidget); - expect(find.byTooltip('Table of contents'), findsOneWidget); + expect(find.byTooltip('Reading settings'), findsNothing); + expect(find.byTooltip('Table of contents'), findsNothing); + await toggleReaderControls(tester); expect(find.byTooltip('Reading settings'), findsOneWidget); }); @@ -850,6 +927,7 @@ void main() { app(document('First sheet'), firstController), ); await tester.pumpAndSettle(); + await toggleReaderControls(tester); await tester.tap(find.byTooltip('Reading settings')); await tester.pumpAndSettle(); expect(find.byType(BottomSheet), findsOneWidget); @@ -861,7 +939,7 @@ void main() { expect(find.byType(BottomSheet), findsNothing); expect(find.text('The Second sheet Reader'), findsNothing); - expect(find.byTooltip('Reading settings'), findsOneWidget); + expect(find.byTooltip('Reading settings'), findsNothing); expect(secondController.snapshot.status, ReaderStatus.ready); expect(firstEngine.isDisposed, isFalse); }, @@ -901,6 +979,7 @@ void main() { ); await tester.pumpAndSettle(); + await toggleReaderControls(tester); await tester.tap(find.byTooltip('Reading settings')); updateHost(() => showReader = false); await tester.pumpAndSettle(); @@ -1337,6 +1416,7 @@ void main() { await pumpReader( tester, document: document('Retry'), + showControls: false, controller: controller, builders: ReaderUiBuilders( loading: (context, state) => const Text('Custom loading'), @@ -1475,7 +1555,7 @@ void main() { expect(controller.snapshot.document!.id, 'Second'); expect(find.text('The Second Reader'), findsNothing); - expect(find.byTooltip('Reading settings'), findsOneWidget); + expect(find.byTooltip('Reading settings'), findsNothing); expect(firstEngine.isDisposed, isTrue); }, ); diff --git a/test/presentation/pdf_selection_test.dart b/test/presentation/pdf_selection_test.dart new file mode 100644 index 0000000..d46feb0 --- /dev/null +++ b/test/presentation/pdf_selection_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/pdf/pdf_facade.dart'; +import 'package:papyrus_reader/src/presentation/reader_interaction_scope.dart'; +import 'package:pdfrx/pdfrx.dart'; + +void main() { + for (final layout in ReaderLayoutMode.values) { + testWidgets( + 'PDF mouse drag opens annotation toolbar in $layout', + (tester) async { + final facade = await tester.runAsync(() async { + await pdfrxInitialize(); + return createPdfrxFacade( + _pdfBytes(), + sourceName: 'selection-$layout', + ); + }); + addTearDown(() => (facade as DisposablePdfFacade).dispose()); + ReaderSelection? annotated; + var turns = 0; + final configuration = PdfViewportConfiguration( + pageIndex: 0, + facingPages: false, + backgroundColor: Colors.white, + brightness: Brightness.light, + layoutMode: layout, + onPageChanged: (_) {}, + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ReaderInteractionScope( + onTurn: (_) => turns++, + onSelectionChanged: (_) {}, + suppressTap: () {}, + onAnnotate: (selection) => annotated = selection, + child: Builder( + builder: (context) => facade!.buildViewport(configuration), + ), + ), + ), + ), + ); + final controller = tester + .widget(find.byType(PdfViewer)) + .controller!; + + for (var frame = 0; frame < 40; frame++) { + await tester.pump(const Duration(milliseconds: 50)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + + expect(controller.isReady, isTrue); + final page = controller.document.pages.first; + final text = (await tester.runAsync(page.loadText))!; + final pageRect = controller.layout.pageLayouts.first; + Offset character(int index) => controller.documentToGlobal( + text.charRects[index].center.toOffsetInDocument( + page: page, + pageRect: pageRect, + ), + )!; + final start = character(0); + final end = character(16); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: start); + await mouse.down(start); + await mouse.moveTo(Offset.lerp(start, end, .5)!); + await tester.pump(const Duration(milliseconds: 50)); + await mouse.moveTo(end); + await tester.pump(const Duration(milliseconds: 50)); + expect(find.text('Annotate'), findsNothing); + await mouse.up(); + + for ( + var frame = 0; + frame < 10 && find.text('Annotate').evaluate().isEmpty; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 50)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + + expect(controller.textSelectionDelegate.hasSelectedText, isTrue); + expect(find.text('Annotate'), findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + expect(find.text('Add note'), findsNothing); + final toolbar = tester.getRect(find.text('Annotate')); + expect(toolbar.bottom, lessThan(start.dy)); + expect(turns, 0); + await tester.tap(find.text('Annotate')); + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(); + expect(annotated, isNotNull); + expect(annotated!.text, contains('Selection')); + expect(annotated!.rectangles, isNotEmpty); + expect((annotated!.start as PdfReaderLocator).pageIndex, 0); + expect(controller.textSelectionDelegate.hasSelectedText, isFalse); + expect(find.text('Annotate'), findsNothing); + await mouse.removePointer(); + await tester.pumpWidget(const SizedBox.shrink()); + expect(tester.takeException(), isNull); + }, + skip: kIsWeb, + ); // Browser PDFium is checked through the example asset server. + } +} + +Uint8List _pdfBytes() { + const content = + 'BT /F1 20 Tf 50 700 Td (Selection toolbar appears here.) Tj ET'; + final objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + '<< /Length ${content.length} >>\nstream\n$content\nendstream', + ]; + final buffer = StringBuffer('%PDF-1.4\n'); + final offsets = []; + for (var index = 0; index < objects.length; index++) { + offsets.add(buffer.length); + buffer.write('${index + 1} 0 obj\n${objects[index]}\nendobj\n'); + } + final xref = buffer.length; + buffer.write('xref\n0 ${objects.length + 1}\n0000000000 65535 f \n'); + for (final offset in offsets) { + buffer.write('${offset.toString().padLeft(10, '0')} 00000 n \n'); + } + buffer.write( + 'trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n$xref\n%%EOF', + ); + return Uint8List.fromList(ascii.encode(buffer.toString())); +} diff --git a/test/presentation/reader_swipe_tracker_test.dart b/test/presentation/reader_swipe_tracker_test.dart new file mode 100644 index 0000000..b1ad0dd --- /dev/null +++ b/test/presentation/reader_swipe_tracker_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/src/presentation/reader_swipe_tracker.dart'; + +void main() { + test('only a single touch drag can become a boundary swipe', () { + for (final kind in [ + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + ]) { + final tracker = ReaderSwipeTracker(); + tracker.start(PointerDownEvent(pointer: 1, kind: kind)); + tracker.update( + PointerMoveEvent( + pointer: 1, + kind: kind, + position: const Offset(100, 0), + ), + ); + final offset = tracker.end( + PointerUpEvent(pointer: 1, kind: kind, position: const Offset(100, 0)), + ); + expect( + offset, + kind == PointerDeviceKind.touch ? const Offset(100, 0) : isNull, + ); + } + }); + + test('selection stays disqualified after it clears during a drag', () { + final tracker = ReaderSwipeTracker(); + tracker.start(const PointerDownEvent(pointer: 1)); + tracker.update( + const PointerMoveEvent(pointer: 1, position: Offset(100, 0)), + ); + tracker.selectionChanged(true); + tracker.selectionChanged(false); + expect( + tracker.end(const PointerUpEvent(pointer: 1, position: Offset(200, 0))), + isNull, + ); + tracker.start(const PointerDownEvent(pointer: 2)); + tracker.update( + const PointerMoveEvent(pointer: 2, position: Offset(-100, 0)), + ); + expect( + tracker.end(const PointerUpEvent(pointer: 2, position: Offset(-100, 0))), + const Offset(-100, 0), + ); + }); + + test('dragging an existing selection cannot turn a page', () { + final tracker = ReaderSwipeTracker()..selectionChanged(true); + tracker.start(const PointerDownEvent(pointer: 1)); + tracker.update( + const PointerMoveEvent(pointer: 1, position: Offset(100, 0)), + ); + tracker.selectionChanged(false); + expect( + tracker.end(const PointerUpEvent(pointer: 1, position: Offset(100, 0))), + isNull, + ); + }); + + test( + 'holding before dragging cancels a swipe without a selection callback', + () { + final tracker = ReaderSwipeTracker(); + tracker.start(const PointerDownEvent(pointer: 1)); + tracker.update( + PointerMoveEvent( + pointer: 1, + position: const Offset(100, 0), + timeStamp: kLongPressTimeout, + ), + ); + expect( + tracker.end(const PointerUpEvent(pointer: 1, position: Offset(100, 0))), + isNull, + ); + }, + ); + + test('a moving swipe can continue longer than the long-press timeout', () { + final tracker = ReaderSwipeTracker(); + tracker.start(const PointerDownEvent(pointer: 1)); + tracker.update(const PointerMoveEvent(pointer: 1, position: Offset(30, 0))); + tracker.update( + PointerMoveEvent( + pointer: 1, + position: const Offset(100, 0), + timeStamp: kLongPressTimeout, + ), + ); + expect( + tracker.end(const PointerUpEvent(pointer: 1, position: Offset(100, 0))), + const Offset(100, 0), + ); + }); + + test('multi-touch and cancelled gestures cannot turn pages', () { + final tracker = ReaderSwipeTracker(); + tracker.start(const PointerDownEvent(pointer: 1)); + tracker.start(const PointerDownEvent(pointer: 2)); + tracker.end(const PointerUpEvent(pointer: 2)); + tracker.update( + const PointerMoveEvent(pointer: 1, position: Offset(100, 0)), + ); + expect( + tracker.end(const PointerUpEvent(pointer: 1, position: Offset(100, 0))), + isNull, + ); + tracker.start(const PointerDownEvent(pointer: 3)); + tracker.update( + const PointerMoveEvent(pointer: 3, position: Offset(100, 0)), + ); + tracker.cancel(const PointerCancelEvent(pointer: 3)); + expect( + tracker.end(const PointerUpEvent(pointer: 3, position: Offset(100, 0))), + isNull, + ); + }); +} diff --git a/test/presentation/reader_text_toolbar_test.dart b/test/presentation/reader_text_toolbar_test.dart new file mode 100644 index 0000000..d734d2d --- /dev/null +++ b/test/presentation/reader_text_toolbar_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/src/presentation/reader_text_toolbar.dart'; + +void main() { + for (final width in [390.0, 1200.0]) { + for (final atTop in [true, false]) { + testWidgets( + 'toolbar stays near text and inside width $width, top $atTop', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 800); + addTearDown(tester.view.reset); + final bounds = Rect.fromLTWH(width - 100, atTop ? 4 : 700, 85, 24); + await tester.pumpWidget( + MaterialApp( + home: Stack( + children: [ + ReaderTextToolbar( + bounds: bounds, + items: [ + ContextMenuButtonItem(label: 'Edit', onPressed: () {}), + ContextMenuButtonItem(label: 'Remove', onPressed: () {}), + ContextMenuButtonItem(label: 'Copy', onPressed: () {}), + ], + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + final edit = tester.getRect( + find.widgetWithText(TextSelectionToolbarTextButton, 'Edit'), + ); + final copy = tester.getRect( + find.widgetWithText(TextSelectionToolbarTextButton, 'Copy'), + ); + final toolbar = edit.expandToInclude(copy); + expect(toolbar.left, greaterThanOrEqualTo(0)); + expect(toolbar.right, lessThanOrEqualTo(width)); + if (atTop) { + expect(toolbar.top, greaterThan(bounds.bottom)); + expect(toolbar.top - bounds.bottom, lessThan(30)); + } else { + expect(toolbar.bottom, lessThan(bounds.top)); + expect(bounds.top - toolbar.bottom, lessThan(30)); + } + expect(tester.takeException(), isNull); + }, + ); + } + } +} diff --git a/test/presentation/real_book_reading_test.dart b/test/presentation/real_book_reading_test.dart index a1b6450..a1a8972 100644 --- a/test/presentation/real_book_reading_test.dart +++ b/test/presentation/real_book_reading_test.dart @@ -98,6 +98,8 @@ void main() { find.byKey(const ValueKey('reader-content')), ); final original = engine.snapshot.locator; + await tester.tapAt(bounds.center); + await tester.pump(const Duration(milliseconds: 350)); await tester.tap(find.byTooltip('Next')); await tester.pump(const Duration(milliseconds: 350)); expect(find.text('Preparing pages…'), findsNothing); @@ -157,6 +159,9 @@ void main() { expect(engine.locator.totalProgression, 0); final content = find.byKey(const ValueKey('reader-content')); final bounds = tester.getRect(content); + expect(find.byTooltip('Reading settings'), findsNothing); + await tester.tapAt(bounds.center); + await tester.pump(const Duration(milliseconds: 350)); final label = tester.getRect(find.text(engine.snapshot.locationLabel!)); final slider = tester.getRect(find.byType(Slider)); expect(slider.center.dy - label.bottom, 11); From 388a12a2ef26c933d59891416c17f4e69e4122e4 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Mon, 26 Oct 2026 21:46:15 +0200 Subject: [PATCH 2/4] fix: preserve PDF keyboard focus and resolve EPUB TOC positions --- lib/src/engine/epub/epub_reader_engine.dart | 25 ++++++++ lib/src/engine/pdf/pdf_selection_toolbar.dart | 19 +++--- test/engine/epub_reader_engine_test.dart | 15 +++++ test/presentation/pdf_selection_test.dart | 64 ++++++++++++++++++- 4 files changed, 112 insertions(+), 11 deletions(-) diff --git a/lib/src/engine/epub/epub_reader_engine.dart b/lib/src/engine/epub/epub_reader_engine.dart index 9bc4239..29517f2 100644 --- a/lib/src/engine/epub/epub_reader_engine.dart +++ b/lib/src/engine/epub/epub_reader_engine.dart @@ -200,6 +200,9 @@ final class EpubReaderEngine extends ReaderEngine { ]; _anchors = (chapter['anchors'] as Map).cast(); _contentLength = chapter['length'] as int; + _toc = [ + for (final entry in _toc) _resolveTocEntry(entry, target.spineIndex), + ]; final offset = target.anchor == null ? target.textOffset : _anchors[target.anchor] ?? target.textOffset; @@ -214,6 +217,28 @@ final class EpubReaderEngine extends ReaderEngine { _restorationRevision++; } + ReaderTocEntry _resolveTocEntry(ReaderTocEntry entry, int spineIndex) { + final locator = entry.locator as EpubReaderLocator; + int? offset; + if (locator.spineIndex == spineIndex) { + offset = locator.anchor == null ? 0 : _anchors[locator.anchor]; + } + return ReaderTocEntry( + title: entry.title, + locator: offset == null + ? locator + : _locatorFor( + spineIndex, + textOffset: offset, + local: _contentLength == 0 ? 0 : offset / _contentLength, + anchor: locator.anchor, + ), + children: [ + for (final child in entry.children) _resolveTocEntry(child, spineIndex), + ], + ); + } + void _requireCurrent(int generation) { if (_disposed || generation != _navigationGeneration) { throw const ReaderException( diff --git a/lib/src/engine/pdf/pdf_selection_toolbar.dart b/lib/src/engine/pdf/pdf_selection_toolbar.dart index 0b40c4f..3d626c3 100644 --- a/lib/src/engine/pdf/pdf_selection_toolbar.dart +++ b/lib/src/engine/pdf/pdf_selection_toolbar.dart @@ -55,6 +55,14 @@ final class _PdfSelectionToolbarState extends State { _menu.remove(); } + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (_menu.isShown && event.logicalKey == LogicalKeyboardKey.escape) { + _dismiss(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + void _scheduleMenu() { final revision = ++_revision; WidgetsBinding.instance.addPostFrameCallback((_) => _show(revision)); @@ -142,14 +150,7 @@ final class _PdfSelectionToolbarState extends State { contextMenuBuilder: (_) => TapRegion( onTapOutside: (_) => _dismiss(), child: Focus( - autofocus: true, - onKeyEvent: (_, event) { - if (event.logicalKey == LogicalKeyboardKey.escape) { - _dismiss(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, + onKeyEvent: _onKeyEvent, child: ReaderTextToolbar(bounds: menuBounds, items: items), ), ), @@ -184,6 +185,6 @@ final class _PdfSelectionToolbarState extends State { _dismiss(); }, onPointerSignal: (_) => _dismiss(), - child: widget.child, + child: Focus(onKeyEvent: _onKeyEvent, child: widget.child), ); } diff --git a/test/engine/epub_reader_engine_test.dart b/test/engine/epub_reader_engine_test.dart index 14d2219..a970042 100644 --- a/test/engine/epub_reader_engine_test.dart +++ b/test/engine/epub_reader_engine_test.dart @@ -50,6 +50,21 @@ void main() { final locator = await engine.currentLocator() as EpubReaderLocator; expect(locator.spineIndex, 1); expect(locator.textOffset, greaterThan(0)); + final section = + engine.snapshot.toc.first.children.single.locator + as EpubReaderLocator; + expect(section.textOffset, locator.textOffset); + expect(section.localProgression, greaterThan(0)); + final chapter = engine.snapshot.toc.first.locator as EpubReaderLocator; + expect(chapter.spineIndex, 0); + expect(chapter.textOffset, 0); + expect(chapter.localProgression, 0); + await engine.goPrevious(); + expect( + (engine.snapshot.toc.first.children.single.locator as EpubReaderLocator) + .textOffset, + section.textOffset, + ); }); test( 'lazily loads a valid EPUB and builds nested table of contents', diff --git a/test/presentation/pdf_selection_test.dart b/test/presentation/pdf_selection_test.dart index d46feb0..1e4db17 100644 --- a/test/presentation/pdf_selection_test.dart +++ b/test/presentation/pdf_selection_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus_reader/papyrus_reader.dart'; import 'package:papyrus_reader/src/engine/pdf/pdf_facade.dart'; @@ -24,6 +25,23 @@ void main() { addTearDown(() => (facade as DisposablePdfFacade).dispose()); ReaderSelection? annotated; var turns = 0; + var keyboardTurns = 0; + final copied = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied.add((call.arguments as Map)['text'] as String); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); final configuration = PdfViewportConfiguration( pageIndex: 0, facingPages: false, @@ -41,8 +59,14 @@ void main() { onSelectionChanged: (_) {}, suppressTap: () {}, onAnnotate: (selection) => annotated = selection, - child: Builder( - builder: (context) => facade!.buildViewport(configuration), + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.pageDown): () => + keyboardTurns++, + }, + child: Builder( + builder: (context) => facade!.buildViewport(configuration), + ), ), ), ), @@ -79,6 +103,7 @@ void main() { await mouse.moveTo(end); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('Annotate'), findsNothing); + final selectionFocus = FocusManager.instance.primaryFocus; await mouse.up(); for ( @@ -99,6 +124,24 @@ void main() { final toolbar = tester.getRect(find.text('Annotate')); expect(toolbar.bottom, lessThan(start.dy)); expect(turns, 0); + await tester.pump(); + expect(FocusManager.instance.primaryFocus, same(selectionFocus)); + + for (final modifier in [ + LogicalKeyboardKey.controlLeft, + LogicalKeyboardKey.metaLeft, + ]) { + await tester.sendKeyDownEvent(modifier); + await tester.sendKeyEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(modifier); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + } + + expect(copied, contains(contains('Selection'))); + await tester.sendKeyEvent(LogicalKeyboardKey.pageDown); + expect(keyboardTurns, 1); await tester.tap(find.text('Annotate')); await tester.pump(); await tester.runAsync( @@ -111,6 +154,23 @@ void main() { expect((annotated!.start as PdfReaderLocator).pageIndex, 0); expect(controller.textSelectionDelegate.hasSelectedText, isFalse); expect(find.text('Annotate'), findsNothing); + + await mouse.down(start); + await mouse.moveTo(end); + await tester.pump(const Duration(milliseconds: 50)); + await mouse.up(); + + for (var frame = 0; frame < 10; frame++) { + await tester.pump(const Duration(milliseconds: 50)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + } + + expect(find.text('Annotate'), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(find.text('Annotate'), findsNothing); await mouse.removePointer(); await tester.pumpWidget(const SizedBox.shrink()); expect(tester.takeException(), isNull); From a6db5a8088710c3ad5ef3efdb205a5eaf617c134 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Mon, 26 Oct 2026 22:14:03 +0200 Subject: [PATCH 3/4] test: initialize PDFium from Flutter native assets on clean runners --- test/presentation/pdf_selection_test.dart | 11 +++++--- test/support/pdfium_test_setup.dart | 2 ++ test/support/pdfium_test_setup_native.dart | 29 ++++++++++++++++++++++ test/support/pdfium_test_setup_stub.dart | 2 ++ 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 test/support/pdfium_test_setup.dart create mode 100644 test/support/pdfium_test_setup_native.dart create mode 100644 test/support/pdfium_test_setup_stub.dart diff --git a/test/presentation/pdf_selection_test.dart b/test/presentation/pdf_selection_test.dart index 1e4db17..414957b 100644 --- a/test/presentation/pdf_selection_test.dart +++ b/test/presentation/pdf_selection_test.dart @@ -10,17 +10,19 @@ import 'package:papyrus_reader/src/engine/pdf/pdf_facade.dart'; import 'package:papyrus_reader/src/presentation/reader_interaction_scope.dart'; import 'package:pdfrx/pdfrx.dart'; +import '../support/pdfium_test_setup.dart'; + void main() { for (final layout in ReaderLayoutMode.values) { testWidgets( 'PDF mouse drag opens annotation toolbar in $layout', (tester) async { final facade = await tester.runAsync(() async { - await pdfrxInitialize(); + await initializePdfiumForTest(); return createPdfrxFacade( _pdfBytes(), sourceName: 'selection-$layout', - ); + ).timeout(const Duration(seconds: 15)); }); addTearDown(() => (facade as DisposablePdfFacade).dispose()); ReaderSelection? annotated; @@ -85,7 +87,9 @@ void main() { expect(controller.isReady, isTrue); final page = controller.document.pages.first; - final text = (await tester.runAsync(page.loadText))!; + final text = (await tester.runAsync( + () => page.loadText().timeout(const Duration(seconds: 15)), + ))!; final pageRect = controller.layout.pageLayouts.first; Offset character(int index) => controller.documentToGlobal( text.charRects[index].center.toOffsetInDocument( @@ -176,6 +180,7 @@ void main() { expect(tester.takeException(), isNull); }, skip: kIsWeb, + timeout: const Timeout(Duration(minutes: 1)), ); // Browser PDFium is checked through the example asset server. } } diff --git a/test/support/pdfium_test_setup.dart b/test/support/pdfium_test_setup.dart new file mode 100644 index 0000000..d06215b --- /dev/null +++ b/test/support/pdfium_test_setup.dart @@ -0,0 +1,2 @@ +export 'pdfium_test_setup_stub.dart' + if (dart.library.io) 'pdfium_test_setup_native.dart'; diff --git a/test/support/pdfium_test_setup_native.dart b/test/support/pdfium_test_setup_native.dart new file mode 100644 index 0000000..4813635 --- /dev/null +++ b/test/support/pdfium_test_setup_native.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:pdfrx/pdfrx.dart'; + +Future initializePdfiumForTest() async { + // Flutter tests emit a host asset manifest here. pdfrx's Dart initializer + // instead looks for .dart_tool/native_assets.yaml, which a clean checkout lacks. + final manifest = File( + 'build/native_assets/${Platform.operatingSystem}/native_assets.json', + ); + final json = jsonDecode(await manifest.readAsString()) as Map; + final targets = json['native-assets'] as Map; + final assets = targets.values.single as Map; + final module = assets['package:pdfium_dart/libpdfium'] as List; + + if (module.first != 'absolute') { + throw StateError('Expected an absolute PDFium path in ${manifest.path}.'); + } + + final modulePath = module.last as String; + if (!File(modulePath).existsSync()) { + throw StateError('PDFium test library is missing: $modulePath'); + } + + Pdfrx.pdfiumModulePath = modulePath; + // The dependency's worker does not forward initialization errors to its caller. + await pdfrxInitialize().timeout(const Duration(seconds: 15)); +} diff --git a/test/support/pdfium_test_setup_stub.dart b/test/support/pdfium_test_setup_stub.dart new file mode 100644 index 0000000..3f97c17 --- /dev/null +++ b/test/support/pdfium_test_setup_stub.dart @@ -0,0 +1,2 @@ +Future initializePdfiumForTest() => + throw UnsupportedError('This PDFium test requires a native test runner.'); From ecc55c4c26b746cd05bdd6b8488da67fd0b6d7a2 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Mon, 26 Oct 2026 22:29:55 +0200 Subject: [PATCH 4/4] test: align browser smoke checks with hidden reader controls --- tool/browser/smoke.mjs | 62 ++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/tool/browser/smoke.mjs b/tool/browser/smoke.mjs index 83b70b3..8a54c48 100644 --- a/tool/browser/smoke.mjs +++ b/tool/browser/smoke.mjs @@ -70,8 +70,37 @@ try { assert.ok(protocol.blocks > 20); assert.equal(protocol.error, 'invalidDocument'); + const settle = () => page.evaluate(() => new Promise(resolve => { + let frames = 0; + const tick = () => ++frames >= 60 ? resolve() : requestAnimationFrame(tick); + requestAnimationFrame(tick); + })); + const tapPage = async () => { + const { width, height } = page.viewportSize(); + await page.mouse.click(width / 2, height / 2); + await settle(); + }; + const assertControlsHidden = async () => { + for (const name of ['Back', 'Reading settings', 'Table of contents', 'Next', 'Previous']) { + assert.equal(await page.getByRole('button', { name, exact: true }).count(), 0, + `${name} must be hidden in focus mode`); + } + assert.equal(await page.getByRole('button', { name: 'Show controls', exact: true }).count(), 0); + }; + const hideControls = async () => { + await tapPage(); + await assertControlsHidden(); + }; + const showInitialControls = async ready => { + await ready.waitFor(); + await settle(); + await assertControlsHidden(); + await tapPage(); + await page.getByRole('button', { name: 'Next', exact: true }).waitFor(); + }; + await page.getByRole('button', { name: 'Explore layouts', exact: true }).click(); - await page.getByRole('button', { name: 'Next', exact: true }).waitFor(); + await showInitialControls(page.getByText('At the Gate', { exact: true }).first()); await page.waitForFunction(() => document.body.innerText.includes('Passage 1.')); await page.getByRole('button', { name: 'Next', exact: true }).click(); await page.waitForFunction(() => !document.body.innerText.includes('Passage 1. Reading')); @@ -84,11 +113,6 @@ try { await mkdir(folder, { recursive: true }); await page.screenshot({ path: resolve(folder, `${name}.png`) }); }; - const settle = () => page.evaluate(() => new Promise(resolve => { - let frames = 0; - const tick = () => ++frames >= 60 ? resolve() : requestAnimationFrame(tick); - requestAnimationFrame(tick); - })); await settle(); const passage = /Passage (\d+)\. Reading/.exec(await page.locator('body').innerText()); assert.ok(passage, 'The next spread must contain readable paragraphs'); @@ -103,20 +127,6 @@ try { assert.ok(beforeWheel && afterWheel && Math.abs(beforeWheel.y - afterWheel.y) < 1, 'A normal paginated column must fit without hidden vertical overflow'); await screenshot('desktop'); - const tapPage = async () => { - const { width, height } = page.viewportSize(); - await page.mouse.click(width / 2, height / 2); - await settle(); - }; - const hideControls = async () => { - await tapPage(); - await settle(); - for (const name of ['Back', 'Reading settings', 'Table of contents', 'Next', 'Previous']) { - assert.equal(await page.getByRole('button', { name, exact: true }).count(), 0, - `${name} must be hidden in focus mode`); - } - assert.equal(await page.getByRole('button', { name: 'Show controls', exact: true }).count(), 0); - }; await hideControls(); const focusedParagraph = await paragraph.boundingBox(); assert.ok(focusedParagraph && focusedParagraph.y < 850 && @@ -164,8 +174,7 @@ try { await (await chooser).setFiles(path); }; await openFile(resolve(reader, 'example/assets/reading_lab.epub')); - await page.getByRole('button', { name: 'Next', exact: true }).waitFor(); - await page.waitForFunction(() => document.body.innerText.includes('At the Gate')); + await showInitialControls(page.getByText('At the Gate', { exact: true }).first()); await page.getByRole('button', { name: 'Back', exact: true }).click(); await settle(); const invalid = resolve(temp, 'invalid.epub'); @@ -181,7 +190,7 @@ try { await page.getByRole('button', { name: 'Read EPUB', exact: true }).click(); await page.getByText('At the Gate', { exact: true }).first().waitFor(); await settle(); - await hideControls(); + await assertControlsHidden(); for (let turn = 0; turn < 3; turn++) { await page.keyboard.press('ArrowRight'); await page.getByText('After the Rain', { exact: true }).first().waitFor(); @@ -197,7 +206,7 @@ try { await page.setViewportSize({ width: 700, height: 360 }); await page.getByRole('button', { name: 'Read PDF', exact: true }).click(); - await page.getByRole('button', { name: 'Next', exact: true }).waitFor(); + await showInitialControls(page.getByText('Page 1 of 3.', { exact: true })); await settle(); await screenshot('pdf-initial'); await page.getByLabel(/Page 1 of 3/).waitFor(); @@ -221,6 +230,7 @@ try { await page.getByRole('button', { name: 'Back', exact: true }).click(); await settle(); await page.getByRole('button', { name: 'Read PDF', exact: true }).click(); + await showInitialControls(page.getByText('Page 2 of 3.', { exact: true })); await page.getByLabel(/Page 2 of 3/).waitFor(); await settle(); await page.getByRole('button', { name: 'Reading settings', exact: true }).click(); @@ -242,7 +252,7 @@ try { await settle(); }; await screenshot('pdf-wide-settings'); - await select('Automatic', 'Single'); + await page.getByRole('button', { name: /Single/ }).waitFor(); await page.getByLabel('Night', { exact: true }).click(); await settle(); await screenshot('pdf-night-single'); @@ -315,7 +325,7 @@ try { await page.getByRole('button', { name: 'Use dark theme', exact: true }).click(); await settle(); await page.getByRole('button', { name: 'Read EPUB', exact: true }).click(); - await page.getByRole('button', { name: 'Reading settings', exact: true }).waitFor(); + await showInitialControls(page.getByText('At the Gate', { exact: true }).first()); await settle(); await page.getByRole('button', { name: 'Reading settings', exact: true }).click(); await settle();