From 20add27a306bc61c862bfaa3a8506515ca38d57e Mon Sep 17 00:00:00 2001 From: Akiba So Date: Thu, 25 Jun 2026 00:11:45 +0800 Subject: [PATCH] feat(pdf): typed-text tool (Windows-Ink friendly) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/editor/canvas/editor_tool.dart | 12 +- lib/editor/canvas/pen_canvas.dart | 5 + lib/editor/canvas/pen_editor_screen.dart | 352 +++++++++++++++++- .../persistence/sidecar_repository.dart | 16 + lib/l10n/app_en.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_zh.dart | 6 + lib/l10n/app_zh.arb | 2 + lib/storage/badnote_sidecar.dart | 102 +++++ test/badnote_sidecar_test.dart | 73 ++++ test/sidecar_repository_test.dart | 42 +++ 12 files changed, 627 insertions(+), 3 deletions(-) diff --git a/lib/editor/canvas/editor_tool.dart b/lib/editor/canvas/editor_tool.dart index 6524fa5..5d945eb 100644 --- a/lib/editor/canvas/editor_tool.dart +++ b/lib/editor/canvas/editor_tool.dart @@ -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 diff --git a/lib/editor/canvas/pen_canvas.dart b/lib/editor/canvas/pen_canvas.dart index 754aafd..17b96a2 100644 --- a/lib/editor/canvas/pen_canvas.dart +++ b/lib/editor/canvas/pen_canvas.dart @@ -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 { diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index e7190fc..252a4b3 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -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 { /// reopen and can be removed via the un-highlight tool). final Map> _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> _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>> _undo = {}; @@ -245,6 +261,12 @@ class _PenEditorScreenState extends State { /// 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 _scratchLinks = []; @@ -280,7 +302,7 @@ class _PenEditorScreenState extends State { /// 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 { for (final entry in loadedHighlights.entries) { _highlightsByPage[entry.key] = entry.value; } + for (final entry in repo.loadedTexts.entries) { + _textsByPage[entry.key] = List.of(entry.value); + } _scratchLinks ..clear() ..addAll(repo.loadedScratchLinks.map((s) => s.link)); @@ -945,6 +970,96 @@ class _PenEditorScreenState extends State { 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.of(_textsByPage[pageIndex] ?? const []), + ); + } + + /// 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.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.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 { _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 { _selectTextMode = true; _placeLinkMode = false; _removeHighlightMode = false; + _textMode = false; _selected = null; }); } @@ -980,6 +1099,7 @@ class _PenEditorScreenState extends State { if (_placeLinkMode) { _selectTextMode = false; _removeHighlightMode = false; + _textMode = false; } }); } @@ -992,6 +1112,7 @@ class _PenEditorScreenState extends State { if (_removeHighlightMode) { _selectTextMode = false; _placeLinkMode = false; + _textMode = false; _selected = null; } }); @@ -1473,6 +1594,57 @@ class _PenEditorScreenState extends State { }, ), ), + // 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 [])) + 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 [])) + 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 { 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 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(), + ), + ); + } +} diff --git a/lib/editor/persistence/sidecar_repository.dart b/lib/editor/persistence/sidecar_repository.dart index e249181..637ff2c 100644 --- a/lib/editor/persistence/sidecar_repository.dart +++ b/lib/editor/persistence/sidecar_repository.dart @@ -137,6 +137,9 @@ class SidecarRepository { /// Page index → highlight rects loaded from the sidecar. Map> get loadedHighlights => _sidecar.highlights; + /// Page index → typed-text annotations loaded from the sidecar. + Map> get loadedTexts => _sidecar.texts; + /// Scratch-link anchors loaded from the sidecar. List 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 texts) { + final next = Map>.from(_sidecar.texts); + if (texts.isEmpty) { + next.remove(pageIndex); + } else { + next[pageIndex] = List.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>? strokes, Map>? highlights, + Map>? texts, List? bookmarks, List? 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), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 74fa7fd..7a1f7d1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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)", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 58a789d..0832a04 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ea04307..afff8aa 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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?'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index bfce416..962709b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -247,6 +247,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get toolPlaceScratchLink => '放置便签链接'; + @override + String get toolText => '文字(点按或双击添加)'; + + @override + String get textPlaceholder => '输入文字…'; + @override String get scratchLinkDeleteTitle => '删除便签链接?'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7a4311d..038043d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -76,6 +76,8 @@ "actionHighlightSelection": "高亮所选", "toolRemoveHighlight": "移除高亮(点按高亮处)", "toolPlaceScratchLink": "放置便签链接", + "toolText": "文字(点按或双击添加)", + "textPlaceholder": "输入文字…", "scratchLinkDeleteTitle": "删除便签链接?", "scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。", "toolAddBookmark": "添加书签(当前位置或所选段落)", diff --git a/lib/storage/badnote_sidecar.dart b/lib/storage/badnote_sidecar.dart index c796b70..3b43a84 100644 --- a/lib/storage/badnote_sidecar.dart +++ b/lib/storage/badnote_sidecar.dart @@ -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 toJson() => { + 'id': id, + 'nx': nx, + 'ny': ny, + 'text': text, + 'fontSize': fontSize, + 'color': color, + }; + + factory SidecarText.fromJson(Map 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>? strokes, Map>? highlights, + Map>? texts, List? bookmarks, List? scratchLinks, Map? legacyAnnotations, @@ -227,6 +317,7 @@ class BadnoteSidecar { this.background, }) : strokes = strokes ?? >{}, highlights = highlights ?? >{}, + texts = texts ?? >{}, bookmarks = bookmarks ?? [], scratchLinks = scratchLinks ?? [], legacyAnnotations = legacyAnnotations ?? {}; @@ -255,6 +346,10 @@ class BadnoteSidecar { /// Page index → highlighted text rects (normalized). final Map> 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> texts; + final List bookmarks; final List 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?) ?? const []) .map((e) => Bookmark.fromJson(e as Map)) .toList(), diff --git a/test/badnote_sidecar_test.dart b/test/badnote_sidecar_test.dart index b9565e6..260742b 100644 --- a/test/badnote_sidecar_test.dart +++ b/test/badnote_sidecar_test.dart @@ -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; + 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( diff --git a/test/sidecar_repository_test.dart b/test/sidecar_repository_test.dart index b70efb4..566b3d3 100644 --- a/test/sidecar_repository_test.dart +++ b/test/sidecar_repository_test.dart @@ -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(