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

@@ -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<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
/// Bookmarks loaded from the sidecar.
List<Bookmark> 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<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.
SidecarScratchpad? scratchpadFor(String linkId) {
for (final s in _sidecar.scratchLinks) {
@@ -274,6 +302,7 @@ class SidecarRepository {
String? title,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
List<Bookmark>? bookmarks,
List<SidecarScratchLink>? 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,