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

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