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:
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user