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

@@ -108,6 +108,95 @@ class SidecarHighlight {
'SidecarHighlight(l: $l, t: $t, r: $r, b: $b, color: $color)';
}
/// A single typed-text annotation on a page (PDF editor for now). Position is
/// NORMALIZED to the page rect ([nx],[ny] in [0,1]) so the box stays glued under
/// zoom/scroll, exactly like [SidecarHighlight] / [ScratchLink]. [fontSize] is
/// PAGE-RELATIVE (a fraction of the page width), so the rendered text scales
/// with the page; the editor multiplies it by the on-screen page width.
class SidecarText {
const SidecarText({
required this.id,
required this.nx,
required this.ny,
required this.text,
this.fontSize = 0.03,
this.color = 0xFF000000,
});
/// Stable id (uuid) so edits/deletes address a specific box.
final String id;
/// Normalized x of the box's top-left in [0,1].
final double nx;
/// Normalized y of the box's top-left in [0,1].
final double ny;
/// The typed text.
final String text;
/// Font size as a fraction of page WIDTH (page-relative; scales with zoom).
final double fontSize;
/// ARGB text color.
final int color;
SidecarText copyWith({
String? id,
double? nx,
double? ny,
String? text,
double? fontSize,
int? color,
}) =>
SidecarText(
id: id ?? this.id,
nx: nx ?? this.nx,
ny: ny ?? this.ny,
text: text ?? this.text,
fontSize: fontSize ?? this.fontSize,
color: color ?? this.color,
);
Map<String, dynamic> toJson() => {
'id': id,
'nx': nx,
'ny': ny,
'text': text,
'fontSize': fontSize,
'color': color,
};
factory SidecarText.fromJson(Map<String, dynamic> json) => SidecarText(
id: json['id'] as String,
nx: (json['nx'] as num).toDouble(),
ny: (json['ny'] as num).toDouble(),
text: (json['text'] as String?) ?? '',
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03,
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarText &&
runtimeType == other.runtimeType &&
id == other.id &&
nx == other.nx &&
ny == other.ny &&
text == other.text &&
fontSize == other.fontSize &&
color == other.color;
@override
int get hashCode => Object.hash(id, nx, ny, text, fontSize, color);
@override
String toString() =>
'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, '
'fontSize: $fontSize, color: $color)';
}
/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
/// world pixels (unchanged format from `SplitViewScreen`), plus the world size
/// so it restores (today the canvas always resets to 4000×4000).
@@ -219,6 +308,7 @@ class BadnoteSidecar {
this.updatedAt,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
Map<int, List<SidecarText>>? texts,
List<Bookmark>? bookmarks,
List<SidecarScratchLink>? scratchLinks,
Map<int, String>? legacyAnnotations,
@@ -227,6 +317,7 @@ class BadnoteSidecar {
this.background,
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
highlights = highlights ?? <int, List<SidecarHighlight>>{},
texts = texts ?? <int, List<SidecarText>>{},
bookmarks = bookmarks ?? <Bookmark>[],
scratchLinks = scratchLinks ?? <SidecarScratchLink>[],
legacyAnnotations = legacyAnnotations ?? <int, String>{};
@@ -255,6 +346,10 @@ class BadnoteSidecar {
/// Page index → highlighted text rects (normalized).
final Map<int, List<SidecarHighlight>> highlights;
/// Page index → typed-text annotations (normalized position, page-relative
/// font size). PDF editor only for now (note text is a later increment).
final Map<int, List<SidecarText>> texts;
final List<Bookmark> bookmarks;
final List<SidecarScratchLink> scratchLinks;
@@ -304,6 +399,12 @@ class BadnoteSidecar {
entry.key.toString():
entry.value.map((h) => h.toJson()).toList(),
},
if (texts.isNotEmpty)
'texts': {
for (final entry in texts.entries)
entry.key.toString():
entry.value.map((t) => t.toJson()).toList(),
},
'bookmarks': bookmarks.map((b) => b.toJson()).toList(),
'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(),
if (legacyAnnotations.isNotEmpty)
@@ -350,6 +451,7 @@ class BadnoteSidecar {
: DateTime.tryParse(json['updatedAt'] as String),
strokes: decodePageMap(json['strokes'], EditorStroke.fromJson),
highlights: decodePageMap(json['highlights'], SidecarHighlight.fromJson),
texts: decodePageMap(json['texts'], SidecarText.fromJson),
bookmarks: ((json['bookmarks'] as List<dynamic>?) ?? const [])
.map((e) => Bookmark.fromJson(e as Map<String, dynamic>))
.toList(),