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 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/bookmark.dart';
|
||||||
import '../../models/scratch_link.dart';
|
import '../../models/scratch_link.dart';
|
||||||
import '../../screens/split_view_screen.dart';
|
import '../../screens/split_view_screen.dart';
|
||||||
import '../../storage/badnote_sidecar.dart';
|
import '../../storage/badnote_sidecar.dart';
|
||||||
@@ -248,6 +249,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
|
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
|
||||||
final List<ScratchLink> _scratchLinks = [];
|
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();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
/// The active drawing color = the active brush's remembered color.
|
/// The active drawing color = the active brush's remembered color.
|
||||||
@@ -364,6 +370,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_scratchLinks
|
_scratchLinks
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(repo.loadedScratchLinks.map((s) => s.link));
|
..addAll(repo.loadedScratchLinks.map((s) => s.link));
|
||||||
|
_bookmarks
|
||||||
|
..clear()
|
||||||
|
..addAll(repo.loadedBookmarks);
|
||||||
});
|
});
|
||||||
_bumpOverlay();
|
_bumpOverlay();
|
||||||
}
|
}
|
||||||
@@ -1065,6 +1074,238 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
setState(() => _scratchLinks.removeWhere((s) => s.id == link.id));
|
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() {
|
void _toggleFingerDrawing() {
|
||||||
final next = !_allowFingerDrawing;
|
final next = !_allowFingerDrawing;
|
||||||
setState(() => _allowFingerDrawing = next);
|
setState(() => _allowFingerDrawing = next);
|
||||||
@@ -1378,6 +1619,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
onPressed: _togglePlaceLinkMode,
|
onPressed: _togglePlaceLinkMode,
|
||||||
),
|
),
|
||||||
PaletteDivider(cs: cs),
|
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).
|
// Undo / redo (per page).
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.undo,
|
icon: Icons.undo,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../../models/bookmark.dart';
|
||||||
import '../../models/scratch_link.dart';
|
import '../../models/scratch_link.dart';
|
||||||
import '../../storage/badnote_sidecar.dart';
|
import '../../storage/badnote_sidecar.dart';
|
||||||
import '../../storage/sidecar_store.dart';
|
import '../../storage/sidecar_store.dart';
|
||||||
@@ -139,6 +140,9 @@ class SidecarRepository {
|
|||||||
/// Scratch-link anchors loaded from the sidecar.
|
/// Scratch-link anchors loaded from the sidecar.
|
||||||
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
|
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
|
||||||
|
|
||||||
|
/// Bookmarks loaded from the sidecar.
|
||||||
|
List<Bookmark> get loadedBookmarks => _sidecar.bookmarks;
|
||||||
|
|
||||||
/// The current in-memory sidecar (for tests / inspection).
|
/// The current in-memory sidecar (for tests / inspection).
|
||||||
BadnoteSidecar get sidecar => _sidecar;
|
BadnoteSidecar get sidecar => _sidecar;
|
||||||
|
|
||||||
@@ -232,6 +236,30 @@ class SidecarRepository {
|
|||||||
_replace(scratchLinks: next);
|
_replace(scratchLinks: next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add (or update, by id) a bookmark and schedule a save.
|
||||||
|
void scheduleBookmarkUpsert(Bookmark bookmark) {
|
||||||
|
final next = List<Bookmark>.of(_sidecar.bookmarks);
|
||||||
|
final idx = next.indexWhere((b) => b.id == bookmark.id);
|
||||||
|
if (idx == -1) {
|
||||||
|
next.add(bookmark);
|
||||||
|
} else {
|
||||||
|
next[idx] = bookmark;
|
||||||
|
}
|
||||||
|
_replace(bookmarks: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the bookmark by [bookmarkId] and schedule a save.
|
||||||
|
void scheduleBookmarkDelete(String bookmarkId) {
|
||||||
|
final next =
|
||||||
|
_sidecar.bookmarks.where((b) => b.id != bookmarkId).toList();
|
||||||
|
_replace(bookmarks: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the whole bookmark list and schedule a save.
|
||||||
|
void scheduleBookmarksSave(List<Bookmark> bookmarks) {
|
||||||
|
_replace(bookmarks: List<Bookmark>.of(bookmarks));
|
||||||
|
}
|
||||||
|
|
||||||
/// The embedded scratchpad for [linkId], or null if the anchor is unknown.
|
/// The embedded scratchpad for [linkId], or null if the anchor is unknown.
|
||||||
SidecarScratchpad? scratchpadFor(String linkId) {
|
SidecarScratchpad? scratchpadFor(String linkId) {
|
||||||
for (final s in _sidecar.scratchLinks) {
|
for (final s in _sidecar.scratchLinks) {
|
||||||
@@ -274,6 +302,7 @@ class SidecarRepository {
|
|||||||
String? title,
|
String? title,
|
||||||
Map<int, List<EditorStroke>>? strokes,
|
Map<int, List<EditorStroke>>? strokes,
|
||||||
Map<int, List<SidecarHighlight>>? highlights,
|
Map<int, List<SidecarHighlight>>? highlights,
|
||||||
|
List<Bookmark>? bookmarks,
|
||||||
List<SidecarScratchLink>? scratchLinks,
|
List<SidecarScratchLink>? scratchLinks,
|
||||||
String? ocrText,
|
String? ocrText,
|
||||||
bool clearOcrText = false,
|
bool clearOcrText = false,
|
||||||
@@ -291,7 +320,7 @@ class SidecarRepository {
|
|||||||
updatedAt: DateTime.now().toUtc(),
|
updatedAt: DateTime.now().toUtc(),
|
||||||
strokes: strokes ?? _sidecar.strokes,
|
strokes: strokes ?? _sidecar.strokes,
|
||||||
highlights: highlights ?? _sidecar.highlights,
|
highlights: highlights ?? _sidecar.highlights,
|
||||||
bookmarks: _sidecar.bookmarks,
|
bookmarks: bookmarks ?? _sidecar.bookmarks,
|
||||||
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
|
||||||
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
|
||||||
background: background ?? _sidecar.background,
|
background: background ?? _sidecar.background,
|
||||||
|
|||||||
@@ -93,6 +93,20 @@
|
|||||||
"toolPlaceScratchLink": "Place scratch link",
|
"toolPlaceScratchLink": "Place scratch link",
|
||||||
"scratchLinkDeleteTitle": "Delete scratch link?",
|
"scratchLinkDeleteTitle": "Delete scratch link?",
|
||||||
"scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.",
|
"scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.",
|
||||||
|
"toolAddBookmark": "Add bookmark (here or at selection)",
|
||||||
|
"toolBookmarks": "Bookmarks",
|
||||||
|
"bookmarksTitle": "Bookmarks",
|
||||||
|
"bookmarksEmpty": "No bookmarks yet.",
|
||||||
|
"bookmarkDefaultLabel": "Page {page}",
|
||||||
|
"@bookmarkDefaultLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"bookmarkPageLabel": "Page {page}",
|
||||||
|
"@bookmarkPageLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"bookmarkDeleteTitle": "Delete bookmark?",
|
||||||
|
"bookmarkDeleteBody": "This removes the saved location.",
|
||||||
"failedToOpenPdf": "Failed to open PDF:\n{error}",
|
"failedToOpenPdf": "Failed to open PDF:\n{error}",
|
||||||
"@failedToOpenPdf": {
|
"@failedToOpenPdf": {
|
||||||
"placeholders": { "error": { "type": "String" } }
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
|||||||
@@ -566,6 +566,54 @@ abstract class AppLocalizations {
|
|||||||
/// **'This removes the anchor and its private scratchpad.'**
|
/// **'This removes the anchor and its private scratchpad.'**
|
||||||
String get scratchLinkDeleteBody;
|
String get scratchLinkDeleteBody;
|
||||||
|
|
||||||
|
/// No description provided for @toolAddBookmark.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Add bookmark (here or at selection)'**
|
||||||
|
String get toolAddBookmark;
|
||||||
|
|
||||||
|
/// No description provided for @toolBookmarks.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bookmarks'**
|
||||||
|
String get toolBookmarks;
|
||||||
|
|
||||||
|
/// No description provided for @bookmarksTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bookmarks'**
|
||||||
|
String get bookmarksTitle;
|
||||||
|
|
||||||
|
/// No description provided for @bookmarksEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No bookmarks yet.'**
|
||||||
|
String get bookmarksEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @bookmarkDefaultLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Page {page}'**
|
||||||
|
String bookmarkDefaultLabel(int page);
|
||||||
|
|
||||||
|
/// No description provided for @bookmarkPageLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Page {page}'**
|
||||||
|
String bookmarkPageLabel(int page);
|
||||||
|
|
||||||
|
/// No description provided for @bookmarkDeleteTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete bookmark?'**
|
||||||
|
String get bookmarkDeleteTitle;
|
||||||
|
|
||||||
|
/// No description provided for @bookmarkDeleteBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This removes the saved location.'**
|
||||||
|
String get bookmarkDeleteBody;
|
||||||
|
|
||||||
/// No description provided for @failedToOpenPdf.
|
/// No description provided for @failedToOpenPdf.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -254,6 +254,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get scratchLinkDeleteBody =>
|
String get scratchLinkDeleteBody =>
|
||||||
'This removes the anchor and its private scratchpad.';
|
'This removes the anchor and its private scratchpad.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolAddBookmark => 'Add bookmark (here or at selection)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolBookmarks => 'Bookmarks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksTitle => 'Bookmarks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksEmpty => 'No bookmarks yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookmarkDefaultLabel(int page) {
|
||||||
|
return 'Page $page';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookmarkPageLabel(int page) {
|
||||||
|
return 'Page $page';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteTitle => 'Delete bookmark?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteBody => 'This removes the saved location.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToOpenPdf(String error) {
|
String failedToOpenPdf(String error) {
|
||||||
return 'Failed to open PDF:\n$error';
|
return 'Failed to open PDF:\n$error';
|
||||||
|
|||||||
@@ -253,6 +253,34 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get scratchLinkDeleteBody => '这会移除锚点及其专属草稿纸。';
|
String get scratchLinkDeleteBody => '这会移除锚点及其专属草稿纸。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolAddBookmark => '添加书签(当前位置或所选段落)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolBookmarks => '书签';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksTitle => '书签';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarksEmpty => '还没有书签。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookmarkDefaultLabel(int page) {
|
||||||
|
return '第 $page 页';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String bookmarkPageLabel(int page) {
|
||||||
|
return '第 $page 页';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteTitle => '删除书签?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bookmarkDeleteBody => '这会移除保存的位置。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToOpenPdf(String error) {
|
String failedToOpenPdf(String error) {
|
||||||
return '打开 PDF 失败:\n$error';
|
return '打开 PDF 失败:\n$error';
|
||||||
|
|||||||
@@ -78,6 +78,20 @@
|
|||||||
"toolPlaceScratchLink": "放置便签链接",
|
"toolPlaceScratchLink": "放置便签链接",
|
||||||
"scratchLinkDeleteTitle": "删除便签链接?",
|
"scratchLinkDeleteTitle": "删除便签链接?",
|
||||||
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
|
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
|
||||||
|
"toolAddBookmark": "添加书签(当前位置或所选段落)",
|
||||||
|
"toolBookmarks": "书签",
|
||||||
|
"bookmarksTitle": "书签",
|
||||||
|
"bookmarksEmpty": "还没有书签。",
|
||||||
|
"bookmarkDefaultLabel": "第 {page} 页",
|
||||||
|
"@bookmarkDefaultLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"bookmarkPageLabel": "第 {page} 页",
|
||||||
|
"@bookmarkPageLabel": {
|
||||||
|
"placeholders": { "page": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"bookmarkDeleteTitle": "删除书签?",
|
||||||
|
"bookmarkDeleteBody": "这会移除保存的位置。",
|
||||||
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
|
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
|
||||||
"pdfNoPages": "PDF 没有任何页面。",
|
"pdfNoPages": "PDF 没有任何页面。",
|
||||||
"pageOfPages": "{current} / {total}",
|
"pageOfPages": "{current} / {total}",
|
||||||
|
|||||||
@@ -3,15 +3,50 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
part 'bookmark.freezed.dart';
|
part 'bookmark.freezed.dart';
|
||||||
part 'bookmark.g.dart';
|
part 'bookmark.g.dart';
|
||||||
|
|
||||||
|
/// A saved location in a document.
|
||||||
|
///
|
||||||
|
/// "Paragraph precision" (user ask: 精确到段落加书签) is expressed by the optional
|
||||||
|
/// in-page anchor fields below, all normalized to the page in [0,1]:
|
||||||
|
///
|
||||||
|
/// * [anchorLeft]/[anchorTop]/[anchorRight]/[anchorBottom] — the bounding rect
|
||||||
|
/// of the bookmarked text fragment (the FIRST fragment of the current text
|
||||||
|
/// selection), in NORMALIZED page coords with a top-left origin (the same
|
||||||
|
/// convention `SidecarHighlight` and the editor's highlight rects use). This
|
||||||
|
/// is what jump-to scrolls to (via `goToRectInsidePage`), so the bookmark
|
||||||
|
/// lands on the exact paragraph, not just the page top.
|
||||||
|
/// * [charIndex] — the character index of the selection start in the page's
|
||||||
|
/// `fullText` (the true text-position anchor). Stored for fidelity / future
|
||||||
|
/// reflow-tolerant re-anchoring; not currently used for navigation.
|
||||||
|
///
|
||||||
|
/// When no text was selected the anchor falls back to the tapped point: only
|
||||||
|
/// [anchorTop]/[anchorLeft] are set (a zero-size rect) and [charIndex] is null.
|
||||||
|
/// All anchor fields are optional and absent from JSON when null, so OLD
|
||||||
|
/// bookmarks (page-only) still decode and re-encode unchanged (back-compat).
|
||||||
@freezed
|
@freezed
|
||||||
abstract class Bookmark with _$Bookmark {
|
abstract class Bookmark with _$Bookmark {
|
||||||
const factory Bookmark({
|
const factory Bookmark({
|
||||||
required String id,
|
required String id,
|
||||||
required String documentId,
|
required String documentId,
|
||||||
|
|
||||||
|
/// 1-based page number this bookmark lives on.
|
||||||
required int pageNumber,
|
required int pageNumber,
|
||||||
@Default('') String label,
|
@Default('') String label,
|
||||||
@Default(0xFF2196F3) int color,
|
@Default(0xFF2196F3) int color,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
|
|
||||||
|
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
|
||||||
|
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
|
||||||
|
/// page-only bookmark JSON omits these keys; they decode to null and the
|
||||||
|
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
|
||||||
|
/// explicit null keys, which readers tolerate.
|
||||||
|
double? anchorLeft,
|
||||||
|
double? anchorTop,
|
||||||
|
double? anchorRight,
|
||||||
|
double? anchorBottom,
|
||||||
|
|
||||||
|
/// Character index of the selection start in the page's `fullText`, or null
|
||||||
|
/// (tap-fallback / legacy bookmarks).
|
||||||
|
int? charIndex,
|
||||||
}) = _Bookmark;
|
}) = _Bookmark;
|
||||||
|
|
||||||
factory Bookmark.fromJson(Map<String, dynamic> json) =>
|
factory Bookmark.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -23,11 +23,27 @@ Bookmark _$BookmarkFromJson(Map<String, dynamic> json) {
|
|||||||
mixin _$Bookmark {
|
mixin _$Bookmark {
|
||||||
String get id => throw _privateConstructorUsedError;
|
String get id => throw _privateConstructorUsedError;
|
||||||
String get documentId => throw _privateConstructorUsedError;
|
String get documentId => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// 1-based page number this bookmark lives on.
|
||||||
int get pageNumber => throw _privateConstructorUsedError;
|
int get pageNumber => throw _privateConstructorUsedError;
|
||||||
String get label => throw _privateConstructorUsedError;
|
String get label => throw _privateConstructorUsedError;
|
||||||
int get color => throw _privateConstructorUsedError;
|
int get color => throw _privateConstructorUsedError;
|
||||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
|
||||||
|
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
|
||||||
|
/// page-only bookmark JSON omits these keys; they decode to null and the
|
||||||
|
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
|
||||||
|
/// explicit null keys, which readers tolerate.
|
||||||
|
double? get anchorLeft => throw _privateConstructorUsedError;
|
||||||
|
double? get anchorTop => throw _privateConstructorUsedError;
|
||||||
|
double? get anchorRight => throw _privateConstructorUsedError;
|
||||||
|
double? get anchorBottom => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Character index of the selection start in the page's `fullText`, or null
|
||||||
|
/// (tap-fallback / legacy bookmarks).
|
||||||
|
int? get charIndex => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
/// Serializes this Bookmark to a JSON map.
|
/// Serializes this Bookmark to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
@@ -50,6 +66,11 @@ abstract class $BookmarkCopyWith<$Res> {
|
|||||||
String label,
|
String label,
|
||||||
int color,
|
int color,
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
|
double? anchorLeft,
|
||||||
|
double? anchorTop,
|
||||||
|
double? anchorRight,
|
||||||
|
double? anchorBottom,
|
||||||
|
int? charIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +95,11 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
|
|||||||
Object? label = null,
|
Object? label = null,
|
||||||
Object? color = null,
|
Object? color = null,
|
||||||
Object? createdAt = null,
|
Object? createdAt = null,
|
||||||
|
Object? anchorLeft = freezed,
|
||||||
|
Object? anchorTop = freezed,
|
||||||
|
Object? anchorRight = freezed,
|
||||||
|
Object? anchorBottom = freezed,
|
||||||
|
Object? charIndex = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_value.copyWith(
|
_value.copyWith(
|
||||||
@@ -101,6 +127,26 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark>
|
|||||||
? _value.createdAt
|
? _value.createdAt
|
||||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,
|
as DateTime,
|
||||||
|
anchorLeft: freezed == anchorLeft
|
||||||
|
? _value.anchorLeft
|
||||||
|
: anchorLeft // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorTop: freezed == anchorTop
|
||||||
|
? _value.anchorTop
|
||||||
|
: anchorTop // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorRight: freezed == anchorRight
|
||||||
|
? _value.anchorRight
|
||||||
|
: anchorRight // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorBottom: freezed == anchorBottom
|
||||||
|
? _value.anchorBottom
|
||||||
|
: anchorBottom // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
charIndex: freezed == charIndex
|
||||||
|
? _value.charIndex
|
||||||
|
: charIndex // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
)
|
)
|
||||||
as $Val,
|
as $Val,
|
||||||
);
|
);
|
||||||
@@ -123,6 +169,11 @@ abstract class _$$BookmarkImplCopyWith<$Res>
|
|||||||
String label,
|
String label,
|
||||||
int color,
|
int color,
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
|
double? anchorLeft,
|
||||||
|
double? anchorTop,
|
||||||
|
double? anchorRight,
|
||||||
|
double? anchorBottom,
|
||||||
|
int? charIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +197,11 @@ class __$$BookmarkImplCopyWithImpl<$Res>
|
|||||||
Object? label = null,
|
Object? label = null,
|
||||||
Object? color = null,
|
Object? color = null,
|
||||||
Object? createdAt = null,
|
Object? createdAt = null,
|
||||||
|
Object? anchorLeft = freezed,
|
||||||
|
Object? anchorTop = freezed,
|
||||||
|
Object? anchorRight = freezed,
|
||||||
|
Object? anchorBottom = freezed,
|
||||||
|
Object? charIndex = freezed,
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_$BookmarkImpl(
|
_$BookmarkImpl(
|
||||||
@@ -173,6 +229,26 @@ class __$$BookmarkImplCopyWithImpl<$Res>
|
|||||||
? _value.createdAt
|
? _value.createdAt
|
||||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,
|
as DateTime,
|
||||||
|
anchorLeft: freezed == anchorLeft
|
||||||
|
? _value.anchorLeft
|
||||||
|
: anchorLeft // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorTop: freezed == anchorTop
|
||||||
|
? _value.anchorTop
|
||||||
|
: anchorTop // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorRight: freezed == anchorRight
|
||||||
|
? _value.anchorRight
|
||||||
|
: anchorRight // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
anchorBottom: freezed == anchorBottom
|
||||||
|
? _value.anchorBottom
|
||||||
|
: anchorBottom // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,
|
||||||
|
charIndex: freezed == charIndex
|
||||||
|
? _value.charIndex
|
||||||
|
: charIndex // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,6 +264,11 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
this.label = '',
|
this.label = '',
|
||||||
this.color = 0xFF2196F3,
|
this.color = 0xFF2196F3,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
|
this.anchorLeft,
|
||||||
|
this.anchorTop,
|
||||||
|
this.anchorRight,
|
||||||
|
this.anchorBottom,
|
||||||
|
this.charIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
|
factory _$BookmarkImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -197,6 +278,8 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
final String id;
|
final String id;
|
||||||
@override
|
@override
|
||||||
final String documentId;
|
final String documentId;
|
||||||
|
|
||||||
|
/// 1-based page number this bookmark lives on.
|
||||||
@override
|
@override
|
||||||
final int pageNumber;
|
final int pageNumber;
|
||||||
@override
|
@override
|
||||||
@@ -208,9 +291,28 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
@override
|
@override
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
|
||||||
|
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
|
||||||
|
/// page-only bookmark JSON omits these keys; they decode to null and the
|
||||||
|
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
|
||||||
|
/// explicit null keys, which readers tolerate.
|
||||||
|
@override
|
||||||
|
final double? anchorLeft;
|
||||||
|
@override
|
||||||
|
final double? anchorTop;
|
||||||
|
@override
|
||||||
|
final double? anchorRight;
|
||||||
|
@override
|
||||||
|
final double? anchorBottom;
|
||||||
|
|
||||||
|
/// Character index of the selection start in the page's `fullText`, or null
|
||||||
|
/// (tap-fallback / legacy bookmarks).
|
||||||
|
@override
|
||||||
|
final int? charIndex;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt)';
|
return 'Bookmark(id: $id, documentId: $documentId, pageNumber: $pageNumber, label: $label, color: $color, createdAt: $createdAt, anchorLeft: $anchorLeft, anchorTop: $anchorTop, anchorRight: $anchorRight, anchorBottom: $anchorBottom, charIndex: $charIndex)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -226,7 +328,17 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
(identical(other.label, label) || other.label == label) &&
|
(identical(other.label, label) || other.label == label) &&
|
||||||
(identical(other.color, color) || other.color == color) &&
|
(identical(other.color, color) || other.color == color) &&
|
||||||
(identical(other.createdAt, createdAt) ||
|
(identical(other.createdAt, createdAt) ||
|
||||||
other.createdAt == createdAt));
|
other.createdAt == createdAt) &&
|
||||||
|
(identical(other.anchorLeft, anchorLeft) ||
|
||||||
|
other.anchorLeft == anchorLeft) &&
|
||||||
|
(identical(other.anchorTop, anchorTop) ||
|
||||||
|
other.anchorTop == anchorTop) &&
|
||||||
|
(identical(other.anchorRight, anchorRight) ||
|
||||||
|
other.anchorRight == anchorRight) &&
|
||||||
|
(identical(other.anchorBottom, anchorBottom) ||
|
||||||
|
other.anchorBottom == anchorBottom) &&
|
||||||
|
(identical(other.charIndex, charIndex) ||
|
||||||
|
other.charIndex == charIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -239,6 +351,11 @@ class _$BookmarkImpl implements _Bookmark {
|
|||||||
label,
|
label,
|
||||||
color,
|
color,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
anchorLeft,
|
||||||
|
anchorTop,
|
||||||
|
anchorRight,
|
||||||
|
anchorBottom,
|
||||||
|
charIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Create a copy of Bookmark
|
/// Create a copy of Bookmark
|
||||||
@@ -263,6 +380,11 @@ abstract class _Bookmark implements Bookmark {
|
|||||||
final String label,
|
final String label,
|
||||||
final int color,
|
final int color,
|
||||||
required final DateTime createdAt,
|
required final DateTime createdAt,
|
||||||
|
final double? anchorLeft,
|
||||||
|
final double? anchorTop,
|
||||||
|
final double? anchorRight,
|
||||||
|
final double? anchorBottom,
|
||||||
|
final int? charIndex,
|
||||||
}) = _$BookmarkImpl;
|
}) = _$BookmarkImpl;
|
||||||
|
|
||||||
factory _Bookmark.fromJson(Map<String, dynamic> json) =
|
factory _Bookmark.fromJson(Map<String, dynamic> json) =
|
||||||
@@ -272,6 +394,8 @@ abstract class _Bookmark implements Bookmark {
|
|||||||
String get id;
|
String get id;
|
||||||
@override
|
@override
|
||||||
String get documentId;
|
String get documentId;
|
||||||
|
|
||||||
|
/// 1-based page number this bookmark lives on.
|
||||||
@override
|
@override
|
||||||
int get pageNumber;
|
int get pageNumber;
|
||||||
@override
|
@override
|
||||||
@@ -281,6 +405,25 @@ abstract class _Bookmark implements Bookmark {
|
|||||||
@override
|
@override
|
||||||
DateTime get createdAt;
|
DateTime get createdAt;
|
||||||
|
|
||||||
|
/// Normalized in-page anchor rect (top-left origin, [0,1]). Null for legacy
|
||||||
|
/// page-only bookmarks (and tap-fallback bookmarks set only top/left). Old
|
||||||
|
/// page-only bookmark JSON omits these keys; they decode to null and the
|
||||||
|
/// data round-trips (back-compat). A re-encoded legacy bookmark gains
|
||||||
|
/// explicit null keys, which readers tolerate.
|
||||||
|
@override
|
||||||
|
double? get anchorLeft;
|
||||||
|
@override
|
||||||
|
double? get anchorTop;
|
||||||
|
@override
|
||||||
|
double? get anchorRight;
|
||||||
|
@override
|
||||||
|
double? get anchorBottom;
|
||||||
|
|
||||||
|
/// Character index of the selection start in the page's `fullText`, or null
|
||||||
|
/// (tap-fallback / legacy bookmarks).
|
||||||
|
@override
|
||||||
|
int? get charIndex;
|
||||||
|
|
||||||
/// Create a copy of Bookmark
|
/// Create a copy of Bookmark
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ _$BookmarkImpl _$$BookmarkImplFromJson(Map<String, dynamic> json) =>
|
|||||||
label: json['label'] as String? ?? '',
|
label: json['label'] as String? ?? '',
|
||||||
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
|
color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3,
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
anchorLeft: (json['anchorLeft'] as num?)?.toDouble(),
|
||||||
|
anchorTop: (json['anchorTop'] as num?)?.toDouble(),
|
||||||
|
anchorRight: (json['anchorRight'] as num?)?.toDouble(),
|
||||||
|
anchorBottom: (json['anchorBottom'] as num?)?.toDouble(),
|
||||||
|
charIndex: (json['charIndex'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
||||||
@@ -24,4 +29,9 @@ Map<String, dynamic> _$$BookmarkImplToJson(_$BookmarkImpl instance) =>
|
|||||||
'label': instance.label,
|
'label': instance.label,
|
||||||
'color': instance.color,
|
'color': instance.color,
|
||||||
'createdAt': instance.createdAt.toIso8601String(),
|
'createdAt': instance.createdAt.toIso8601String(),
|
||||||
|
'anchorLeft': instance.anchorLeft,
|
||||||
|
'anchorTop': instance.anchorTop,
|
||||||
|
'anchorRight': instance.anchorRight,
|
||||||
|
'anchorBottom': instance.anchorBottom,
|
||||||
|
'charIndex': instance.charIndex,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -219,6 +219,65 @@ void main() {
|
|||||||
expect(reparsed.version, kBadnoteSidecarVersion);
|
expect(reparsed.version, kBadnoteSidecarVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('paragraph-anchored bookmark round-trips its anchor + char index', () {
|
||||||
|
final original = BadnoteSidecar(bookmarks: [
|
||||||
|
Bookmark(
|
||||||
|
id: 'bm-anchor',
|
||||||
|
documentId: 'doc1',
|
||||||
|
pageNumber: 3,
|
||||||
|
label: 'A bookmarked paragraph',
|
||||||
|
color: 0xFF2196F3,
|
||||||
|
createdAt: DateTime.utc(2026, 6, 24, 11, 0, 0),
|
||||||
|
anchorLeft: 0.12,
|
||||||
|
anchorTop: 0.20,
|
||||||
|
anchorRight: 0.88,
|
||||||
|
anchorBottom: 0.235,
|
||||||
|
charIndex: 1234,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
final reparsed = _roundTrip(original);
|
||||||
|
expect(reparsed.bookmarks, original.bookmarks);
|
||||||
|
final bm = reparsed.bookmarks.single;
|
||||||
|
expect(bm.pageNumber, 3);
|
||||||
|
expect(bm.anchorLeft, 0.12);
|
||||||
|
expect(bm.anchorTop, 0.20);
|
||||||
|
expect(bm.anchorRight, 0.88);
|
||||||
|
expect(bm.anchorBottom, 0.235);
|
||||||
|
expect(bm.charIndex, 1234);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy page-only bookmark JSON decodes (anchor fields absent)', () {
|
||||||
|
// A bookmark authored before the anchor fields existed: only page-level.
|
||||||
|
final legacyJson = {
|
||||||
|
'badnoteSidecarVersion': 1,
|
||||||
|
'bookmarks': [
|
||||||
|
{
|
||||||
|
'id': 'legacy1',
|
||||||
|
'documentId': 'doc1',
|
||||||
|
'pageNumber': 7,
|
||||||
|
'label': 'Old bookmark',
|
||||||
|
'color': 0xFF2196F3,
|
||||||
|
'createdAt': '2026-06-01T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
final decoded = BadnoteSidecar.fromJson(legacyJson);
|
||||||
|
final bm = decoded.bookmarks.single;
|
||||||
|
expect(bm.pageNumber, 7);
|
||||||
|
expect(bm.anchorLeft, isNull);
|
||||||
|
expect(bm.anchorTop, isNull);
|
||||||
|
expect(bm.anchorRight, isNull);
|
||||||
|
expect(bm.anchorBottom, isNull);
|
||||||
|
expect(bm.charIndex, isNull);
|
||||||
|
// Re-encoding preserves the page-only data; any anchor keys are null.
|
||||||
|
final reJson = bm.toJson();
|
||||||
|
expect(reJson['anchorLeft'], isNull);
|
||||||
|
expect(reJson['charIndex'], isNull);
|
||||||
|
expect(reJson['pageNumber'], 7);
|
||||||
|
// And it decodes back to an equal page-only bookmark.
|
||||||
|
expect(Bookmark.fromJson(reJson), bm);
|
||||||
|
});
|
||||||
|
|
||||||
test('unknown fields are ignored (forward-compat)', () {
|
test('unknown fields are ignored (forward-compat)', () {
|
||||||
final encoded = jsonEncode(BadnoteSidecar(
|
final encoded = jsonEncode(BadnoteSidecar(
|
||||||
strokes: {0: [_editorStroke('s0', EditorTool.pen)]},
|
strokes: {0: [_editorStroke('s0', EditorTool.pen)]},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
|
|
||||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
import 'package:badnote/editor/persistence/sidecar_repository.dart';
|
import 'package:badnote/editor/persistence/sidecar_repository.dart';
|
||||||
|
import 'package:badnote/models/bookmark.dart';
|
||||||
import 'package:badnote/models/ink_point.dart';
|
import 'package:badnote/models/ink_point.dart';
|
||||||
import 'package:badnote/models/ink_stroke.dart';
|
import 'package:badnote/models/ink_stroke.dart';
|
||||||
import 'package:badnote/models/pen_tool.dart';
|
import 'package:badnote/models/pen_tool.dart';
|
||||||
@@ -183,6 +184,81 @@ void main() {
|
|||||||
after.dispose();
|
after.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('paragraph-anchored bookmark restores on reopen', () async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
repo.scheduleBookmarkUpsert(Bookmark(
|
||||||
|
id: 'bm-1',
|
||||||
|
documentId: src,
|
||||||
|
pageNumber: 4,
|
||||||
|
label: 'A precise paragraph',
|
||||||
|
createdAt: DateTime.utc(2026, 6, 24, 12),
|
||||||
|
anchorLeft: 0.12,
|
||||||
|
anchorTop: 0.34,
|
||||||
|
anchorRight: 0.88,
|
||||||
|
anchorBottom: 0.37,
|
||||||
|
charIndex: 512,
|
||||||
|
));
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
|
||||||
|
final reopened = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
final bm = reopened.loadedBookmarks.single;
|
||||||
|
expect(bm.id, 'bm-1');
|
||||||
|
expect(bm.pageNumber, 4);
|
||||||
|
expect(bm.label, 'A precise paragraph');
|
||||||
|
expect(bm.anchorLeft, 0.12);
|
||||||
|
expect(bm.anchorTop, 0.34);
|
||||||
|
expect(bm.anchorRight, 0.88);
|
||||||
|
expect(bm.anchorBottom, 0.37);
|
||||||
|
expect(bm.charIndex, 512);
|
||||||
|
reopened.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a bookmark persists', () async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
repo.scheduleBookmarkUpsert(Bookmark(
|
||||||
|
id: 'bm-a',
|
||||||
|
documentId: src,
|
||||||
|
pageNumber: 1,
|
||||||
|
createdAt: DateTime.utc(2026, 6, 24),
|
||||||
|
));
|
||||||
|
repo.scheduleBookmarkUpsert(Bookmark(
|
||||||
|
id: 'bm-b',
|
||||||
|
documentId: src,
|
||||||
|
pageNumber: 2,
|
||||||
|
createdAt: DateTime.utc(2026, 6, 24),
|
||||||
|
));
|
||||||
|
await repo.flush();
|
||||||
|
repo.scheduleBookmarkDelete('bm-a');
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
|
||||||
|
final after = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
expect(after.loadedBookmarks.map((b) => b.id), ['bm-b']);
|
||||||
|
after.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upserting a bookmark by id updates it in place', () async {
|
||||||
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
final base = Bookmark(
|
||||||
|
id: 'bm-x',
|
||||||
|
documentId: src,
|
||||||
|
pageNumber: 1,
|
||||||
|
label: 'old',
|
||||||
|
createdAt: DateTime.utc(2026, 6, 24),
|
||||||
|
);
|
||||||
|
repo.scheduleBookmarkUpsert(base);
|
||||||
|
repo.scheduleBookmarkUpsert(base.copyWith(label: 'new', pageNumber: 9));
|
||||||
|
await repo.flush();
|
||||||
|
repo.dispose();
|
||||||
|
|
||||||
|
final after = await SidecarRepository.open(src, debounce: _fast);
|
||||||
|
expect(after.loadedBookmarks.length, 1);
|
||||||
|
expect(after.loadedBookmarks.single.label, 'new');
|
||||||
|
expect(after.loadedBookmarks.single.pageNumber, 9);
|
||||||
|
after.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('upserting a scratch link preserves its existing scratchpad', () async {
|
test('upserting a scratch link preserves its existing scratchpad', () async {
|
||||||
final repo = await SidecarRepository.open(src, debounce: _fast);
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
const link =
|
const link =
|
||||||
|
|||||||
Reference in New Issue
Block a user