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

@@ -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(),
),
);
}
}