feat(pdf): paragraph-precise bookmarks
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:
2026-06-25 00:00:16 +08:00
parent c800295c12
commit 1d5ba05bb8
12 changed files with 743 additions and 3 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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" } }

View File

@@ -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:

View File

@@ -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';

View File

@@ -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';

View File

@@ -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}",

View File

@@ -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) =>

View File

@@ -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

View File

@@ -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,
}; };

View File

@@ -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)]},

View File

@@ -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 =