diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 0383c6c..e7190fc 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -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 { /// add/delete. Rendered as tappable markers in [pageOverlaysBuilder]. final List _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 _bookmarks = []; + static const _uuid = Uuid(); /// The active drawing color = the active brush's remembered color. @@ -364,6 +370,9 @@ class _PenEditorScreenState extends State { _scratchLinks ..clear() ..addAll(repo.loadedScratchLinks.map((s) => s.link)); + _bookmarks + ..clear() + ..addAll(repo.loadedBookmarks); }); _bumpOverlay(); } @@ -1065,6 +1074,238 @@ class _PenEditorScreenState extends State { 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 _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 _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 _confirmDeleteBookmark(Bookmark bookmark) async { + final l = AppLocalizations.of(context); + final confirmed = await showDialog( + 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( + 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 { 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, diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart index dd0de54..e249181 100644 --- a/lib/editor/persistence/sidecar_repository.dart +++ b/lib/editor/persistence/sidecar_repository.dart @@ -21,6 +21,7 @@ import 'dart:async'; import 'dart:io'; +import '../../models/bookmark.dart'; import '../../models/scratch_link.dart'; import '../../storage/badnote_sidecar.dart'; import '../../storage/sidecar_store.dart'; @@ -139,6 +140,9 @@ class SidecarRepository { /// Scratch-link anchors loaded from the sidecar. List get loadedScratchLinks => _sidecar.scratchLinks; + /// Bookmarks loaded from the sidecar. + List get loadedBookmarks => _sidecar.bookmarks; + /// The current in-memory sidecar (for tests / inspection). BadnoteSidecar get sidecar => _sidecar; @@ -232,6 +236,30 @@ class SidecarRepository { _replace(scratchLinks: next); } + /// Add (or update, by id) a bookmark and schedule a save. + void scheduleBookmarkUpsert(Bookmark bookmark) { + final next = List.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 bookmarks) { + _replace(bookmarks: List.of(bookmarks)); + } + /// The embedded scratchpad for [linkId], or null if the anchor is unknown. SidecarScratchpad? scratchpadFor(String linkId) { for (final s in _sidecar.scratchLinks) { @@ -274,6 +302,7 @@ class SidecarRepository { String? title, Map>? strokes, Map>? highlights, + List? bookmarks, List? scratchLinks, String? ocrText, bool clearOcrText = false, @@ -291,7 +320,7 @@ class SidecarRepository { updatedAt: DateTime.now().toUtc(), strokes: strokes ?? _sidecar.strokes, highlights: highlights ?? _sidecar.highlights, - bookmarks: _sidecar.bookmarks, + bookmarks: bookmarks ?? _sidecar.bookmarks, scratchLinks: scratchLinks ?? _sidecar.scratchLinks, ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText), background: background ?? _sidecar.background, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0639124..74fa7fd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -93,6 +93,20 @@ "toolPlaceScratchLink": "Place scratch link", "scratchLinkDeleteTitle": "Delete scratch link?", "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": { "placeholders": { "error": { "type": "String" } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7a3d3e1..58a789d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -566,6 +566,54 @@ abstract class AppLocalizations { /// **'This removes the anchor and its private scratchpad.'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b725fc4..ea04307 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -254,6 +254,34 @@ class AppLocalizationsEn extends AppLocalizations { String get scratchLinkDeleteBody => '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 String failedToOpenPdf(String error) { return 'Failed to open PDF:\n$error'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0ea29c6..bfce416 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -253,6 +253,34 @@ class AppLocalizationsZh extends AppLocalizations { @override 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 String failedToOpenPdf(String error) { return '打开 PDF 失败:\n$error'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b98dd35..7a4311d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -78,6 +78,20 @@ "toolPlaceScratchLink": "放置便签链接", "scratchLinkDeleteTitle": "删除便签链接?", "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}", "pdfNoPages": "PDF 没有任何页面。", "pageOfPages": "{current} / {total}", diff --git a/lib/models/bookmark.dart b/lib/models/bookmark.dart index c318053..4ac1a8b 100644 --- a/lib/models/bookmark.dart +++ b/lib/models/bookmark.dart @@ -3,15 +3,50 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'bookmark.freezed.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 abstract class Bookmark with _$Bookmark { const factory Bookmark({ required String id, required String documentId, + + /// 1-based page number this bookmark lives on. required int pageNumber, @Default('') String label, @Default(0xFF2196F3) int color, 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; factory Bookmark.fromJson(Map json) => diff --git a/lib/models/bookmark.freezed.dart b/lib/models/bookmark.freezed.dart index 47b7a8e..d06cd16 100644 --- a/lib/models/bookmark.freezed.dart +++ b/lib/models/bookmark.freezed.dart @@ -23,11 +23,27 @@ Bookmark _$BookmarkFromJson(Map json) { mixin _$Bookmark { String get id => throw _privateConstructorUsedError; String get documentId => throw _privateConstructorUsedError; + + /// 1-based page number this bookmark lives on. int get pageNumber => throw _privateConstructorUsedError; String get label => throw _privateConstructorUsedError; int get color => 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. Map toJson() => throw _privateConstructorUsedError; @@ -50,6 +66,11 @@ abstract class $BookmarkCopyWith<$Res> { String label, int color, 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? color = null, Object? createdAt = null, + Object? anchorLeft = freezed, + Object? anchorTop = freezed, + Object? anchorRight = freezed, + Object? anchorBottom = freezed, + Object? charIndex = freezed, }) { return _then( _value.copyWith( @@ -101,6 +127,26 @@ class _$BookmarkCopyWithImpl<$Res, $Val extends Bookmark> ? _value.createdAt : createdAt // ignore: cast_nullable_to_non_nullable 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, ); @@ -123,6 +169,11 @@ abstract class _$$BookmarkImplCopyWith<$Res> String label, int color, DateTime createdAt, + double? anchorLeft, + double? anchorTop, + double? anchorRight, + double? anchorBottom, + int? charIndex, }); } @@ -146,6 +197,11 @@ class __$$BookmarkImplCopyWithImpl<$Res> Object? label = null, Object? color = null, Object? createdAt = null, + Object? anchorLeft = freezed, + Object? anchorTop = freezed, + Object? anchorRight = freezed, + Object? anchorBottom = freezed, + Object? charIndex = freezed, }) { return _then( _$BookmarkImpl( @@ -173,6 +229,26 @@ class __$$BookmarkImplCopyWithImpl<$Res> ? _value.createdAt : createdAt // ignore: cast_nullable_to_non_nullable 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.color = 0xFF2196F3, required this.createdAt, + this.anchorLeft, + this.anchorTop, + this.anchorRight, + this.anchorBottom, + this.charIndex, }); factory _$BookmarkImpl.fromJson(Map json) => @@ -197,6 +278,8 @@ class _$BookmarkImpl implements _Bookmark { final String id; @override final String documentId; + + /// 1-based page number this bookmark lives on. @override final int pageNumber; @override @@ -208,9 +291,28 @@ class _$BookmarkImpl implements _Bookmark { @override 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 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 @@ -226,7 +328,17 @@ class _$BookmarkImpl implements _Bookmark { (identical(other.label, label) || other.label == label) && (identical(other.color, color) || other.color == color) && (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) @@ -239,6 +351,11 @@ class _$BookmarkImpl implements _Bookmark { label, color, createdAt, + anchorLeft, + anchorTop, + anchorRight, + anchorBottom, + charIndex, ); /// Create a copy of Bookmark @@ -263,6 +380,11 @@ abstract class _Bookmark implements Bookmark { final String label, final int color, required final DateTime createdAt, + final double? anchorLeft, + final double? anchorTop, + final double? anchorRight, + final double? anchorBottom, + final int? charIndex, }) = _$BookmarkImpl; factory _Bookmark.fromJson(Map json) = @@ -272,6 +394,8 @@ abstract class _Bookmark implements Bookmark { String get id; @override String get documentId; + + /// 1-based page number this bookmark lives on. @override int get pageNumber; @override @@ -281,6 +405,25 @@ abstract class _Bookmark implements Bookmark { @override 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 /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/models/bookmark.g.dart b/lib/models/bookmark.g.dart index 8e822aa..75ac6b9 100644 --- a/lib/models/bookmark.g.dart +++ b/lib/models/bookmark.g.dart @@ -14,6 +14,11 @@ _$BookmarkImpl _$$BookmarkImplFromJson(Map json) => label: json['label'] as String? ?? '', color: (json['color'] as num?)?.toInt() ?? 0xFF2196F3, 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 _$$BookmarkImplToJson(_$BookmarkImpl instance) => @@ -24,4 +29,9 @@ Map _$$BookmarkImplToJson(_$BookmarkImpl instance) => 'label': instance.label, 'color': instance.color, 'createdAt': instance.createdAt.toIso8601String(), + 'anchorLeft': instance.anchorLeft, + 'anchorTop': instance.anchorTop, + 'anchorRight': instance.anchorRight, + 'anchorBottom': instance.anchorBottom, + 'charIndex': instance.charIndex, }; diff --git a/test/badnote_sidecar_test.dart b/test/badnote_sidecar_test.dart index 5b4bb2d..b9565e6 100644 --- a/test/badnote_sidecar_test.dart +++ b/test/badnote_sidecar_test.dart @@ -219,6 +219,65 @@ void main() { 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)', () { final encoded = jsonEncode(BadnoteSidecar( strokes: {0: [_editorStroke('s0', EditorTool.pen)]}, diff --git a/test/sidecar_repository_test.dart b/test/sidecar_repository_test.dart index 0505378..b70efb4 100644 --- a/test/sidecar_repository_test.dart +++ b/test/sidecar_repository_test.dart @@ -15,6 +15,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:badnote/editor/engine/stroke_model.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_stroke.dart'; import 'package:badnote/models/pen_tool.dart'; @@ -183,6 +184,81 @@ void main() { 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 { final repo = await SidecarRepository.open(src, debounce: _fast); const link =