feat(pdf): paragraph-precise bookmarks
Some checks failed
CI / Windows build (push) Has been cancelled
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:
@@ -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)]},
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user