feat(pdf): typed-text tool (Windows-Ink friendly)
Some checks failed
CI / Windows build (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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?';
|
||||
|
||||
|
||||
@@ -247,6 +247,12 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get toolPlaceScratchLink => '放置便签链接';
|
||||
|
||||
@override
|
||||
String get toolText => '文字(点按或双击添加)';
|
||||
|
||||
@override
|
||||
String get textPlaceholder => '输入文字…';
|
||||
|
||||
@override
|
||||
String get scratchLinkDeleteTitle => '删除便签链接?';
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
"actionHighlightSelection": "高亮所选",
|
||||
"toolRemoveHighlight": "移除高亮(点按高亮处)",
|
||||
"toolPlaceScratchLink": "放置便签链接",
|
||||
"toolText": "文字(点按或双击添加)",
|
||||
"textPlaceholder": "输入文字…",
|
||||
"scratchLinkDeleteTitle": "删除便签链接?",
|
||||
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
|
||||
"toolAddBookmark": "添加书签(当前位置或所选段落)",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user