feat(pdf): typed-text tool (Windows-Ink friendly)
Some checks failed
CI / Windows build (push) Has been cancelled

Add a text-annotation tool to the PDF editor. With the text tool a
pen-tap, or a mouse double-click, drops a text box at that normalized
page point and focuses a real Flutter TextField — so the OS IME and the
Windows-Ink handwriting panel feed it (device-validated). Tapping an
existing box re-opens it; clearing it deletes it.

- SidecarText {nx, ny, text, fontSize (page-relative), color} per page,
  glued under zoom; stored in the sidecar `texts` field (back-compat
  missing -> none), saved via scheduleTextsSave and loaded on open.
- Rendered in pageOverlaysBuilder at the scaled position.

PDF editor only for now (note text later). analyze clean, 397 tests.
This commit is contained in:
2026-06-25 00:11:45 +08:00
parent 1d5ba05bb8
commit 20add27a30
12 changed files with 627 additions and 3 deletions

View File

@@ -76,6 +76,21 @@ BadnoteSidecar _fullSidecar() => BadnoteSidecar(
SidecarHighlight(l: 0.12, t: 0.20, r: 0.88, b: 0.235, color: 0xFFFFEB3B),
],
},
texts: {
0: const [
SidecarText(
id: 't0',
nx: 0.25,
ny: 0.33,
text: 'Hello 手写',
fontSize: 0.04,
color: 0xFF112233,
),
],
3: const [
SidecarText(id: 't1', nx: 0.5, ny: 0.6, text: 'second'),
],
},
bookmarks: [
Bookmark(
id: 'bm1',
@@ -131,6 +146,11 @@ void main() {
// Highlights.
expect(reparsed.highlights[0], original.highlights[0]);
// Typed-text annotations (SidecarText value equality).
expect(reparsed.texts.keys.toSet(), {0, 3});
expect(reparsed.texts[0], original.texts[0]);
expect(reparsed.texts[3], original.texts[3]);
// Bookmarks (Bookmark value equality via freezed).
expect(reparsed.bookmarks, original.bookmarks);
@@ -214,11 +234,64 @@ void main() {
final reparsed = _roundTrip(BadnoteSidecar());
expect(reparsed.strokes, isEmpty);
expect(reparsed.highlights, isEmpty);
expect(reparsed.texts, isEmpty);
expect(reparsed.bookmarks, isEmpty);
expect(reparsed.scratchLinks, isEmpty);
expect(reparsed.version, kBadnoteSidecarVersion);
});
test('text annotation round-trips (page, nx/ny, text, fontSize, color)', () {
final original = BadnoteSidecar(texts: {
2: const [
SidecarText(
id: 'tx1',
nx: 0.18,
ny: 0.42,
text: 'A typed note 手写测试',
fontSize: 0.035,
color: 0xFFAB12CD,
),
],
});
final reparsed = _roundTrip(original);
expect(reparsed.texts.keys.toSet(), {2});
final t = reparsed.texts[2]!.single;
expect(t.id, 'tx1');
expect(t.nx, 0.18);
expect(t.ny, 0.42);
expect(t.text, 'A typed note 手写测试');
expect(t.fontSize, 0.035);
expect(t.color, 0xFFAB12CD);
// Full SidecarText value equality.
expect(reparsed.texts[2], original.texts[2]);
});
test('text JSON is byte-compatible with SidecarText.toJson', () {
const tx = SidecarText(id: 't0', nx: 0.25, ny: 0.33, text: 'hi');
final sidecar = BadnoteSidecar(texts: {1: const [tx]});
final json = sidecar.toJson();
final pageList = (json['texts'] as Map)['1'] as List;
expect(pageList.single, tx.toJson());
});
test('missing texts decodes to empty (back-compat)', () {
// A sidecar authored before the texts field existed (no `texts` key).
final json =
jsonDecode(jsonEncode(BadnoteSidecar().toJson())) as Map<String, dynamic>;
expect(json.containsKey('texts'), isFalse,
reason: 'empty texts omitted (no key bloat for legacy sidecars)');
final reparsed = BadnoteSidecar.fromJson(json);
expect(reparsed.texts, isEmpty);
});
test('SidecarText defaults fill in for a minimal JSON blob', () {
// Only the required fields present; fontSize/color/text fall to defaults.
final t = SidecarText.fromJson(const {'id': 'a', 'nx': 0.1, 'ny': 0.2});
expect(t.text, '');
expect(t.fontSize, 0.03);
expect(t.color, 0xFF000000);
});
test('paragraph-anchored bookmark round-trips its anchor + char index', () {
final original = BadnoteSidecar(bookmarks: [
Bookmark(

View File

@@ -161,6 +161,48 @@ void main() {
after.dispose();
});
test('typed-text annotations persist + restore on reopen', () async {
final repo = await SidecarRepository.open(src, debounce: _fast);
repo.scheduleTextsSave(2, const [
SidecarText(
id: 'tx1',
nx: 0.2,
ny: 0.3,
text: 'glued note',
fontSize: 0.04,
color: 0xFF112233,
),
]);
await repo.flush();
repo.dispose();
final after = await SidecarRepository.open(src, debounce: _fast);
final t = after.loadedTexts[2]!.single;
expect(t.id, 'tx1');
expect(t.nx, 0.2);
expect(t.ny, 0.3);
expect(t.text, 'glued note');
expect(t.fontSize, 0.04);
expect(t.color, 0xFF112233);
after.dispose();
});
test('clearing all text on a page removes the page entry (empty-on-blur)',
() async {
final repo = await SidecarRepository.open(src, debounce: _fast);
repo.scheduleTextsSave(
0, const [SidecarText(id: 'a', nx: 0.1, ny: 0.1, text: 'x')]);
await repo.flush();
// The box is emptied + deleted (empty-on-blur), leaving no texts on page 0.
repo.scheduleTextsSave(0, const []);
await repo.flush();
repo.dispose();
final after = await SidecarRepository.open(src, debounce: _fast);
expect(after.loadedTexts.containsKey(0), isFalse);
after.dispose();
});
test('deleting a scratch link removes it and its scratchpad', () async {
final repo = await SidecarRepository.open(src, debounce: _fast);
repo.scheduleScratchLinkUpsert(const ScratchLink(