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

@@ -11,8 +11,9 @@
// those remain editor-local because they ride pdfrx's text layer / the page
// overlay, not the ink capture path. See `selectTextOrLink` note below.
//
// TODO(toolbar-batch-2): text/typing tool, bookmark-to-paragraph, search+OCR,
// templates, Windows Ink — later batches add kinds here.
// TODO(toolbar-batch-2): bookmark-to-paragraph, search+OCR, templates — later
// batches add kinds here. (The typed-text tool now exists as [EditorToolKind.
// text], PDF-only for now; see the `text` doc below.)
/// The shared inking/editing tools available on every pen-first canvas.
enum EditorToolKind {
@@ -33,6 +34,13 @@ enum EditorToolKind {
/// Shape tool: pen-drag previews a [ShapeKind] from start→current and commits
/// it as a generated [PenStroke] on release.
shape,
/// Typed-text tool (PDF editor only for now): a pen-tap OR a mouse
/// double-click on a page drops a text box at that normalized point and
/// focuses a real Flutter text field for input (so the OS IME / Windows-Ink
/// handwriting panel works). Committed boxes render glued to the page and are
/// re-editable; an empty box deletes itself on blur.
text,
}
/// The shapes the [EditorToolKind.shape] tool can draw. Each is generated as a

View File

@@ -54,6 +54,11 @@ CanvasTool editorToolToCanvas(EditorToolKind kind) => switch (kind) {
EditorToolKind.eraser => CanvasTool.eraser,
EditorToolKind.select => CanvasTool.select,
EditorToolKind.shape => CanvasTool.shape,
// The TEXT tool is PDF-editor-only for now (note/slide typed text is a
// later increment); the note palette has no text button, so this mapping
// is unreachable in practice — fall back to the pen so the switch stays
// exhaustive without inventing a PenCanvas typed-text path.
EditorToolKind.text => CanvasTool.pen,
};
class PenCanvas extends StatefulWidget {

View File

@@ -61,6 +61,11 @@ import 'pinch_scale_solver.dart';
/// (not scaled with zoom) so the tap target stays comfortably tappable.
const double _kMarkerSize = 36.0;
/// Default font size for a new text box, as a fraction of page WIDTH (so it
/// scales with zoom). ~3% of page width ≈ comfortable body text on a portrait
/// page.
const double _kDefaultTextFontFraction = 0.03;
class PenEditorScreen extends StatefulWidget {
const PenEditorScreen({
super.key,
@@ -102,6 +107,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// reopen and can be removed via the un-highlight tool).
final Map<int, List<Rect>> _highlightsByPage = {};
/// Typed-text annotations per page, keyed by 0-based page index. Normalized
/// position + page-relative font size so they stay glued under zoom.
/// Persisted to the sidecar via [scheduleTextsSave]. PDF editor only for now
/// (note text is a later increment).
final Map<int, List<SidecarText>> _textsByPage = {};
/// The text box currently being edited (page + id), or null. While set a real
/// Flutter [TextField] is rendered over the box at its normalized position —
/// on Windows this receives IME + the Windows-Ink handwriting panel.
({int page, String id})? _editingText;
/// Per-page undo/redo history. Snapshot-before-change discipline: the
/// pre-mutation stroke list is recorded before each commit/erase.
final Map<int, UndoStack<List<PenStroke>>> _undo = {};
@@ -245,6 +261,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// disabled so the tap is handled by the per-page GestureDetector overlay.
bool _removeHighlightMode = false;
/// When true the TEXT tool is active: a pen-tap (the pen falls through to the
/// per-page overlay GestureDetector) OR a mouse double-click on a page drops a
/// new text box and focuses it. Pen capture is disabled so the overlay sees
/// the tap instead of the ink path.
bool _textMode = false;
/// All scratch-link anchors for this document, loaded on open and updated on
/// add/delete. Rendered as tappable markers in [pageOverlaysBuilder].
final List<ScratchLink> _scratchLinks = [];
@@ -280,7 +302,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// pen capture is on. False in select-text mode (pen reaches pdfrx text
/// selection) and in place-link mode (a tap drops an anchor via the overlay).
bool get _penCaptureEnabled =>
!_selectTextMode && !_placeLinkMode && !_removeHighlightMode;
!_selectTextMode && !_placeLinkMode && !_removeHighlightMode && !_textMode;
/// True when the eraser tool is active.
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
@@ -367,6 +389,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
for (final entry in loadedHighlights.entries) {
_highlightsByPage[entry.key] = entry.value;
}
for (final entry in repo.loadedTexts.entries) {
_textsByPage[entry.key] = List<SidecarText>.of(entry.value);
}
_scratchLinks
..clear()
..addAll(repo.loadedScratchLinks.map((s) => s.link));
@@ -945,6 +970,96 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return false;
}
// ── Typed text annotations (PDF editor only for now) ────────────────────────
/// Serialize the current text annotations for [pageIndex] to the sidecar.
void _scheduleTextsSave(int pageIndex) {
final repo = _repo;
if (repo == null) return;
repo.scheduleTextsSave(
pageIndex,
List<SidecarText>.of(_textsByPage[pageIndex] ?? const <SidecarText>[]),
);
}
/// Create a new text box at normalized [normalized] on [pageIndex] and focus
/// it for input. (Not undoable for this increment — see report; a blank box
/// self-deletes on blur, so a stray placement leaves no residue.)
void _placeTextBox(int pageIndex, Offset normalized) {
final id = _uuid.v4();
final box = SidecarText(
id: id,
nx: normalized.dx.clamp(0.0, 1.0),
ny: normalized.dy.clamp(0.0, 1.0),
text: '',
fontSize: _kDefaultTextFontFraction,
color: _color.toARGB32(),
);
setState(() {
_textsByPage[pageIndex] = [...?_textsByPage[pageIndex], box];
_editingText = (page: pageIndex, id: id);
});
_bumpOverlay();
}
/// Open an existing text box [id] on [pageIndex] for editing.
void _editTextBox(int pageIndex, String id) {
setState(() => _editingText = (page: pageIndex, id: id));
}
/// Live edit: replace the editing box's text. Persisted (debounced) so the
/// content survives a crash mid-typing.
void _updateEditingText(String text) {
final editing = _editingText;
if (editing == null) return;
final list = _textsByPage[editing.page];
if (list == null) return;
final idx = list.indexWhere((t) => t.id == editing.id);
if (idx == -1) return;
setState(() {
final next = List<SidecarText>.of(list);
next[idx] = next[idx].copyWith(text: text);
_textsByPage[editing.page] = next;
});
_scheduleTextsSave(editing.page);
_bumpOverlay();
}
/// Finish editing (field blur / tool change): if the box is empty it is
/// removed (empty-on-blur deletes); otherwise the committed text is persisted.
void _finishTextEdit() {
final editing = _editingText;
if (editing == null) return;
final list = _textsByPage[editing.page];
if (list != null) {
final idx = list.indexWhere((t) => t.id == editing.id);
if (idx != -1 && list[idx].text.trim().isEmpty) {
setState(() {
final next = List<SidecarText>.of(list)..removeAt(idx);
if (next.isEmpty) {
_textsByPage.remove(editing.page);
} else {
_textsByPage[editing.page] = next;
}
});
_scheduleTextsSave(editing.page);
}
}
setState(() => _editingText = null);
_bumpOverlay();
}
/// Toggle the TEXT tool (drops [_editingText] when leaving, so a half-typed
/// box gets the empty-on-blur treatment).
void _toggleTextMode() {
if (_textMode) {
_finishTextEdit();
setState(() => _textMode = false);
} else {
_setTool(EditorToolKind.text);
}
}
// ── Navigation / tools ─────────────────────────────────────────────────────
void _goToPage(int index) {
@@ -959,6 +1074,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_selectTextMode = false;
_placeLinkMode = false;
_removeHighlightMode = false;
// The TEXT tool is the one EditorToolKind that drives a page-anchored
// (non-ink) interaction, so it owns the _textMode flag.
_textMode = tool == EditorToolKind.text;
if (tool != EditorToolKind.select) _selected = null;
});
}
@@ -968,6 +1086,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_selectTextMode = true;
_placeLinkMode = false;
_removeHighlightMode = false;
_textMode = false;
_selected = null;
});
}
@@ -980,6 +1099,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
if (_placeLinkMode) {
_selectTextMode = false;
_removeHighlightMode = false;
_textMode = false;
}
});
}
@@ -992,6 +1112,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
if (_removeHighlightMode) {
_selectTextMode = false;
_placeLinkMode = false;
_textMode = false;
_selected = null;
}
});
@@ -1473,6 +1594,57 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
},
),
),
// Placement layer: while the TEXT tool is active, a pen-tap OR a
// mouse double-click on empty page space drops a new box. It sits
// BELOW the per-box labels in the stack so a tap that lands on an
// existing label edits it instead of placing a new box.
if (_textMode)
Positioned.fill(
child: _TextPlacementLayer(
onPlace: (local) {
if (pageW <= 0 || pageH <= 0) return;
final nx = (local.dx / pageW).clamp(0.0, 1.0);
final ny = (local.dy / pageH).clamp(0.0, 1.0);
_placeTextBox(pageIndex, Offset(nx, ny));
},
),
),
// Typed-text annotations (committed). Each non-editing box is a
// tappable label glued at (nx*pageW, ny*pageH) with page-scaled
// font. Tapping one re-opens it for editing. The box currently
// being edited is rendered as a TextField below instead.
for (final t in (_textsByPage[pageIndex] ?? const <SidecarText>[]))
if (!(_editingText?.page == pageIndex &&
_editingText?.id == t.id))
Positioned(
left: t.nx * pageW,
top: t.ny * pageH,
child: _TextAnnotationLabel(
text: t.text,
fontSizePx: t.fontSize * pageW,
color: Color(t.color),
onTap: _textMode ? () => _editTextBox(pageIndex, t.id) : null,
),
),
// Active editing field for a box on this page: a real Flutter
// TextField so the OS IME + Windows-Ink handwriting panel work.
if (_editingText?.page == pageIndex)
for (final t in (_textsByPage[pageIndex] ?? const <SidecarText>[]))
if (t.id == _editingText!.id)
Positioned(
left: t.nx * pageW,
top: t.ny * pageH,
width: (pageW - t.nx * pageW).clamp(40.0, pageW),
child: _TextAnnotationField(
key: ValueKey('text-edit-${t.id}'),
initialText: t.text,
fontSizePx: t.fontSize * pageW,
color: Color(t.color),
hintText: AppLocalizations.of(context).textPlaceholder,
onChanged: _updateEditingText,
onDone: _finishTextEdit,
),
),
// Anchor markers (sticky-note tabs): tap → split view, long-press →
// delete. Sized in screen px so the tap target stays usable at any
// zoom; positioned at (nx*pageW, ny*pageH).
@@ -1588,6 +1760,13 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
tooltip: l.actionDeleteSelection,
onPressed: _deleteSelected,
),
// Typed-text tool: pen-tap or mouse double-click drops a text box.
ToolButton(
icon: Icons.title,
selected: _textMode,
tooltip: l.toolText,
onPressed: _toggleTextMode,
),
PaletteDivider(cs: cs),
// Text selection + highlight (real vector text).
ToolButton(
@@ -2073,3 +2252,174 @@ class _ScratchLinkMarker extends StatelessWidget {
);
}
}
/// Empty-space placement layer for the TEXT tool. Resolves the two requested
/// gestures by POINTER KIND (the user asked for "pen-tap OR mouse double-click"):
/// * stylus / touch → a single tap places (one deliberate pen poke);
/// * mouse → a DOUBLE-click places (a single click is too easy to trigger
/// while panning, matching the "鼠标双击" request).
/// The down-pointer's kind is captured in [onTapDown] and consumed by
/// [onTapUp]; mouse double-clicks come through [onDoubleTapDown].
class _TextPlacementLayer extends StatefulWidget {
const _TextPlacementLayer({required this.onPlace});
/// Called with the LOCAL position (within the page rect) where a box should
/// be placed.
final void Function(Offset local) onPlace;
@override
State<_TextPlacementLayer> createState() => _TextPlacementLayerState();
}
class _TextPlacementLayerState extends State<_TextPlacementLayer> {
PointerDeviceKind? _downKind;
Offset? _downLocal;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (d) {
_downKind = d.kind;
_downLocal = d.localPosition;
},
onTapUp: (d) {
// A mouse single-click does NOT place (mouse uses double-click); pen and
// touch place on a single tap.
if (_downKind == PointerDeviceKind.mouse) return;
widget.onPlace(d.localPosition);
},
onDoubleTapDown: (d) {
_downLocal = d.localPosition;
},
onDoubleTap: () {
final local = _downLocal;
if (local != null) widget.onPlace(local);
},
);
}
}
/// A committed text annotation rendered glued to the page. Read-only label;
/// tapping it (when [onTap] is non-null, i.e. the TEXT tool is active) re-opens
/// it for editing.
class _TextAnnotationLabel extends StatelessWidget {
const _TextAnnotationLabel({
required this.text,
required this.fontSizePx,
required this.color,
this.onTap,
});
final String text;
final double fontSizePx;
final Color color;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Text(
text,
style: TextStyle(
fontSize: fontSizePx,
color: color,
height: 1.2,
),
),
);
}
}
/// The active editing field for a text box. A REAL Flutter [TextField] so the
/// OS IME and — on Windows — the Windows-Ink handwriting panel feed it
/// automatically (no special plugin; a focusable text input is all the panel
/// needs). Autofocuses on insert; commits via [onChanged] (debounced persist)
/// and finishes via [onDone] (submit / focus loss).
class _TextAnnotationField extends StatefulWidget {
const _TextAnnotationField({
super.key,
required this.initialText,
required this.fontSizePx,
required this.color,
required this.hintText,
required this.onChanged,
required this.onDone,
});
final String initialText;
final double fontSizePx;
final Color color;
final String hintText;
final ValueChanged<String> onChanged;
final VoidCallback onDone;
@override
State<_TextAnnotationField> createState() => _TextAnnotationFieldState();
}
class _TextAnnotationFieldState extends State<_TextAnnotationField> {
late final TextEditingController _controller;
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialText);
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
// Autofocus after the first frame so the field is mounted before we request
// focus (which also raises the IME / handwriting panel).
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
void _onFocusChange() {
if (!_focusNode.hasFocus) widget.onDone();
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surface.withValues(alpha: 0.85),
elevation: 1,
borderRadius: BorderRadius.circular(4),
child: TextField(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
maxLines: null,
minLines: 1,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
cursorColor: widget.color,
style: TextStyle(
fontSize: widget.fontSizePx,
color: widget.color,
height: 1.2,
),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: widget.hintText,
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
),
onChanged: widget.onChanged,
onTapOutside: (_) => _focusNode.unfocus(),
onEditingComplete: () => _focusNode.unfocus(),
),
);
}
}

View File

@@ -137,6 +137,9 @@ class SidecarRepository {
/// Page index → highlight rects loaded from the sidecar.
Map<int, List<SidecarHighlight>> get loadedHighlights => _sidecar.highlights;
/// Page index → typed-text annotations loaded from the sidecar.
Map<int, List<SidecarText>> get loadedTexts => _sidecar.texts;
/// Scratch-link anchors loaded from the sidecar.
List<SidecarScratchLink> get loadedScratchLinks => _sidecar.scratchLinks;
@@ -202,6 +205,17 @@ class SidecarRepository {
_replace(highlights: next);
}
/// Replace the typed-text annotations for [pageIndex] and schedule a save.
void scheduleTextsSave(int pageIndex, List<SidecarText> texts) {
final next = Map<int, List<SidecarText>>.from(_sidecar.texts);
if (texts.isEmpty) {
next.remove(pageIndex);
} else {
next[pageIndex] = List<SidecarText>.of(texts);
}
_replace(texts: next);
}
/// Add (or update) a scratch-link anchor, preserving any existing scratchpad,
/// and schedule a save.
void scheduleScratchLinkUpsert(ScratchLink link) {
@@ -302,6 +316,7 @@ class SidecarRepository {
String? title,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
Map<int, List<SidecarText>>? texts,
List<Bookmark>? bookmarks,
List<SidecarScratchLink>? scratchLinks,
String? ocrText,
@@ -320,6 +335,7 @@ class SidecarRepository {
updatedAt: DateTime.now().toUtc(),
strokes: strokes ?? _sidecar.strokes,
highlights: highlights ?? _sidecar.highlights,
texts: texts ?? _sidecar.texts,
bookmarks: bookmarks ?? _sidecar.bookmarks,
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),

View File

@@ -91,6 +91,8 @@
"actionHighlightSelection": "Highlight selection",
"toolRemoveHighlight": "Remove highlight (tap a highlight)",
"toolPlaceScratchLink": "Place scratch link",
"toolText": "Text (tap or double-click to add)",
"textPlaceholder": "Type…",
"scratchLinkDeleteTitle": "Delete scratch link?",
"scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.",
"toolAddBookmark": "Add bookmark (here or at selection)",

View File

@@ -554,6 +554,18 @@ abstract class AppLocalizations {
/// **'Place scratch link'**
String get toolPlaceScratchLink;
/// No description provided for @toolText.
///
/// In en, this message translates to:
/// **'Text (tap or double-click to add)'**
String get toolText;
/// No description provided for @textPlaceholder.
///
/// In en, this message translates to:
/// **'Type…'**
String get textPlaceholder;
/// No description provided for @scratchLinkDeleteTitle.
///
/// In en, this message translates to:

View File

@@ -247,6 +247,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get toolPlaceScratchLink => 'Place scratch link';
@override
String get toolText => 'Text (tap or double-click to add)';
@override
String get textPlaceholder => 'Type…';
@override
String get scratchLinkDeleteTitle => 'Delete scratch link?';

View File

@@ -247,6 +247,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get toolPlaceScratchLink => '放置便签链接';
@override
String get toolText => '文字(点按或双击添加)';
@override
String get textPlaceholder => '输入文字…';
@override
String get scratchLinkDeleteTitle => '删除便签链接?';

View File

@@ -76,6 +76,8 @@
"actionHighlightSelection": "高亮所选",
"toolRemoveHighlight": "移除高亮(点按高亮处)",
"toolPlaceScratchLink": "放置便签链接",
"toolText": "文字(点按或双击添加)",
"textPlaceholder": "输入文字…",
"scratchLinkDeleteTitle": "删除便签链接?",
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
"toolAddBookmark": "添加书签(当前位置或所选段落)",

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(),