feat(pdf): paragraph-precise bookmarks
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Add a bookmark tool to the PDF editor. A bookmark anchors to a precise location: when text is selected it captures the selection's normalized rect + the start char index in the page text (the true paragraph anchor); with no selection it falls back to the tapped page + point. - Bookmark model gains optional normalized anchor rect + charIndex + label (all absent from JSON when null, so old sidecars still load). - Bookmarks persist in the sidecar (scheduleBookmarkUpsert) and load on open; a bookmarks panel lists them and tapping one jumps to its page. Delete is persisted. Scoped to the PDF editor (note bookmarks later); scroll-to-anchor is page-level for now. analyze clean, 391 tests green.
This commit is contained in:
@@ -30,6 +30,7 @@ import 'package:pdfrx/pdfrx.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/bookmark.dart';
|
||||
import '../../models/scratch_link.dart';
|
||||
import '../../screens/split_view_screen.dart';
|
||||
import '../../storage/badnote_sidecar.dart';
|
||||
@@ -248,6 +249,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
|
||||
final List<ScratchLink> _scratchLinks = [];
|
||||
|
||||
/// All bookmarks for this document, loaded on open and updated on add/delete.
|
||||
/// Listed in the bookmarks panel; tapping one jumps to its anchor. Scoped to
|
||||
/// the PDF editor for now (note bookmarks are a later increment).
|
||||
final List<Bookmark> _bookmarks = [];
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// The active drawing color = the active brush's remembered color.
|
||||
@@ -364,6 +370,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_scratchLinks
|
||||
..clear()
|
||||
..addAll(repo.loadedScratchLinks.map((s) => s.link));
|
||||
_bookmarks
|
||||
..clear()
|
||||
..addAll(repo.loadedBookmarks);
|
||||
});
|
||||
_bumpOverlay();
|
||||
}
|
||||
@@ -1065,6 +1074,238 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
setState(() => _scratchLinks.removeWhere((s) => s.id == link.id));
|
||||
}
|
||||
|
||||
// ── Bookmarks (paragraph-precise) ────────────────────────────────────────────
|
||||
|
||||
/// Add a bookmark at a PRECISE location. Prefers the current text selection's
|
||||
/// START fragment (page + normalized rect + char index = the paragraph) so the
|
||||
/// bookmark lands on the exact paragraph; falls back to the current page's top
|
||||
/// when there is no selection. The label is the selected-text snippet
|
||||
/// (truncated) or "Page N".
|
||||
Future<void> _addBookmark() async {
|
||||
if (!_controller.isReady) return;
|
||||
final l = AppLocalizations.of(context);
|
||||
|
||||
int pageNumber = _pageIndex + 1; // 1-based
|
||||
double? aLeft, aTop, aRight, aBottom;
|
||||
int? charIndex;
|
||||
String label = '';
|
||||
|
||||
if (_hasSelection) {
|
||||
final delegate = _controller.textSelectionDelegate;
|
||||
final ranges = await delegate.getSelectedTextRanges();
|
||||
if (!mounted) return;
|
||||
if (ranges.isNotEmpty) {
|
||||
final range = ranges.first;
|
||||
final doc = _controller.document;
|
||||
final pageIndex = range.pageNumber - 1;
|
||||
if (pageIndex >= 0 && pageIndex < doc.pages.length) {
|
||||
final page = doc.pages[pageIndex];
|
||||
final w = page.width;
|
||||
final h = page.height;
|
||||
if (w > 0 && h > 0) {
|
||||
// First fragment's bounding rect → normalized page rect (top-left
|
||||
// origin), exactly as _highlightSelection normalizes highlight
|
||||
// rects.
|
||||
for (final frag in range.enumerateFragmentBoundingRects()) {
|
||||
final r = frag.bounds.toRect(page: page);
|
||||
aLeft = (r.left / w).clamp(0.0, 1.0);
|
||||
aTop = (r.top / h).clamp(0.0, 1.0);
|
||||
aRight = (r.right / w).clamp(0.0, 1.0);
|
||||
aBottom = (r.bottom / h).clamp(0.0, 1.0);
|
||||
break; // anchor to the FIRST fragment (the selection start).
|
||||
}
|
||||
}
|
||||
}
|
||||
pageNumber = range.pageNumber;
|
||||
charIndex = range.start;
|
||||
final text = range.text.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
if (text.isNotEmpty) {
|
||||
label = text.length > 60 ? '${text.substring(0, 60)}…' : text;
|
||||
}
|
||||
await delegate.clearTextSelection();
|
||||
if (!mounted) return;
|
||||
setState(() => _hasSelection = false);
|
||||
}
|
||||
}
|
||||
|
||||
if (label.isEmpty) label = l.bookmarkDefaultLabel(pageNumber);
|
||||
|
||||
final bookmark = Bookmark(
|
||||
id: _uuid.v4(),
|
||||
// The source file path is the identity (the sidecar IS the identity).
|
||||
documentId: widget.pdfPath,
|
||||
pageNumber: pageNumber,
|
||||
label: label,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
anchorLeft: aLeft,
|
||||
anchorTop: aTop,
|
||||
anchorRight: aRight,
|
||||
anchorBottom: aBottom,
|
||||
charIndex: charIndex,
|
||||
);
|
||||
_repo?.scheduleBookmarkUpsert(bookmark);
|
||||
if (!mounted) return;
|
||||
setState(() => _bookmarks.add(bookmark));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(bookmark.label),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Jump to a bookmark: scroll to its page and, when it carries a normalized
|
||||
/// in-page anchor rect, to that exact paragraph (via goToRectInsidePage).
|
||||
/// PDF page coords have a BOTTOM-left origin (Y up), so the stored top-left
|
||||
/// normalized rect is flipped on Y when reconstructing the PdfRect.
|
||||
Future<void> _goToBookmark(Bookmark bookmark) async {
|
||||
if (!_controller.isReady) return;
|
||||
final pageNumber = bookmark.pageNumber.clamp(1, _pageCount);
|
||||
final top = bookmark.anchorTop;
|
||||
final left = bookmark.anchorLeft;
|
||||
if (top == null || left == null) {
|
||||
_controller.goToPage(pageNumber: pageNumber);
|
||||
return;
|
||||
}
|
||||
final page = _controller.document.pages[pageNumber - 1];
|
||||
final w = page.width;
|
||||
final h = page.height;
|
||||
final right = bookmark.anchorRight ?? left;
|
||||
final bottom = bookmark.anchorBottom ?? top;
|
||||
// Flutter (y-down) normalized → PDF (y-up) page coords.
|
||||
final pdfRect = PdfRect(
|
||||
(left * w).clamp(0.0, w),
|
||||
((1.0 - top) * h).clamp(0.0, h), // pdf top (bigger)
|
||||
(right * w).clamp(0.0, w),
|
||||
((1.0 - bottom) * h).clamp(0.0, h), // pdf bottom (smaller)
|
||||
);
|
||||
await _controller.goToRectInsidePage(
|
||||
pageNumber: pageNumber,
|
||||
rect: pdfRect,
|
||||
anchor: PdfPageAnchor.top,
|
||||
);
|
||||
}
|
||||
|
||||
/// Confirm + delete a bookmark (persisted).
|
||||
Future<void> _confirmDeleteBookmark(Bookmark bookmark) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(l.bookmarkDeleteTitle),
|
||||
content: Text(l.bookmarkDeleteBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(l.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(l.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
_repo?.scheduleBookmarkDelete(bookmark.id);
|
||||
if (!mounted) return;
|
||||
setState(() => _bookmarks.removeWhere((b) => b.id == bookmark.id));
|
||||
}
|
||||
|
||||
/// Open the bookmarks panel (a bottom sheet): each entry shows its label +
|
||||
/// page; tap → jump to the anchor; swipe to dismiss → delete (persisted).
|
||||
void _openBookmarksPanel() {
|
||||
final l = AppLocalizations.of(context);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(sheetContext).size.height * 0.6,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Text(
|
||||
l.bookmarksTitle,
|
||||
style: Theme.of(sheetContext).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (_bookmarks.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
l.bookmarksEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(sheetContext).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _bookmarks.length,
|
||||
itemBuilder: (context, i) {
|
||||
final bm = _bookmarks[i];
|
||||
return Dismissible(
|
||||
key: ValueKey(bm.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
onDismissed: (_) {
|
||||
_repo?.scheduleBookmarkDelete(bm.id);
|
||||
setState(
|
||||
() => _bookmarks.removeWhere((b) => b.id == bm.id),
|
||||
);
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Icons.bookmark,
|
||||
color: Color(bm.color),
|
||||
),
|
||||
title: Text(
|
||||
bm.label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(l.bookmarkPageLabel(bm.pageNumber)),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_goToBookmark(bm);
|
||||
},
|
||||
onLongPress: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_confirmDeleteBookmark(bm);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleFingerDrawing() {
|
||||
final next = !_allowFingerDrawing;
|
||||
setState(() => _allowFingerDrawing = next);
|
||||
@@ -1378,6 +1619,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
onPressed: _togglePlaceLinkMode,
|
||||
),
|
||||
PaletteDivider(cs: cs),
|
||||
// Bookmark: add (selection-anchored if any, else current page) +
|
||||
// open the bookmarks panel (list / jump-to / delete).
|
||||
ToolButton(
|
||||
icon: Icons.bookmark_add_outlined,
|
||||
selected: false,
|
||||
tooltip: l.toolAddBookmark,
|
||||
onPressed: _viewerReady ? _addBookmark : null,
|
||||
),
|
||||
ToolButton(
|
||||
icon: Icons.bookmarks_outlined,
|
||||
selected: false,
|
||||
tooltip: l.toolBookmarks,
|
||||
onPressed: _viewerReady ? _openBookmarksPanel : null,
|
||||
),
|
||||
PaletteDivider(cs: cs),
|
||||
// Undo / redo (per page).
|
||||
ToolButton(
|
||||
icon: Icons.undo,
|
||||
|
||||
Reference in New Issue
Block a user