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

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