fix: Surface pen pressure, zoom glitches, sticky notes, selection UX
All checks were successful
CI / Windows build (push) Successful in 8m19s

Wire Win32 pressure into Dart, tighten pinch guards, use geometric shape
strokes, expand the ink palette, and replace scratch-link split view with
an on-page sticky that shares the sidecar repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 19:52:15 +08:00
parent 198da00ecd
commit 4a6fe7d05e
16 changed files with 621 additions and 171 deletions

View File

@@ -32,7 +32,6 @@ import 'package:uuid/uuid.dart';
import '../../l10n/app_localizations.dart';
import '../../models/bookmark.dart';
import '../../models/scratch_link.dart';
import '../../screens/split_view_screen.dart';
import '../../storage/badnote_sidecar.dart';
import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
@@ -56,6 +55,7 @@ import 'input_diagnostics.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
import 'pinch_scale_solver.dart';
import 'sticky_note_overlay.dart';
/// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space
/// (not scaled with zoom) so the tap target stays comfortably tappable.
@@ -154,11 +154,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// A real pinch changes scale modestly per frame; a frame demanding far more
/// is a Windows multi-touch glitch and is dropped (so the zoom can't pop).
static const double _kScaleGlitchHi = 1.4;
/// Logs showed ~1.30 spikes — keep the band below that.
static const double _kScaleGlitchHi = 1.18;
static const double _kScaleGlitchLo = 1 / _kScaleGlitchHi;
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
static const double _kFocalGlitchPx = 250.0;
static const double _kFocalGlitchPx = 100.0;
/// Matrix scale captured at the current baseline (gesture start or the last
/// pointer-count re-baseline). Null when no pinch is active.
@@ -247,9 +248,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
? BrushKind.highlighter
: _penBrush;
/// When true the "select text" tool is active: pen capture is disabled so the
/// pen falls through to pdfrx for native text selection.
bool _selectTextMode = false;
/// When true the "select text" tool button is latched on.
bool _selectTextTool = false;
/// Barrel-held temporary select-text (OneNote-style); ORed into [_selectTextMode].
bool _barrelSelectText = false;
/// Select-text is active from the tool button OR a held barrel mapped to
/// [PenButtonAction.selectText].
bool get _selectTextMode => _selectTextTool || _barrelSelectText;
/// Expanded paper-sticky overlay for a scratch link (null = collapsed).
ScratchLink? _expandedSticky;
/// When true the "place scratch link" tool is active: a tap on a page drops a
/// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled
@@ -290,19 +300,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = [
Colors.black,
Colors.red,
Colors.blue,
Colors.green,
Colors.orange,
];
static const List<Color> _palette = kInkPalette;
/// True when an ink tool (brush/highlighter/eraser/select/shape) is active —
/// 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).
/// selection), place-link / sticky expanded, and text mode.
bool get _penCaptureEnabled =>
!_selectTextMode && !_placeLinkMode && !_removeHighlightMode && !_textMode;
!_selectTextMode &&
!_placeLinkMode &&
!_removeHighlightMode &&
!_textMode &&
_expandedSticky == null;
/// True when the eraser tool is active.
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
@@ -320,6 +328,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// Begin listening to the native Windows pen plugin (barrel/eraser/tilt).
// No-op on platforms without the plugin (W3).
PenInputService.instance.start();
PenInputService.instance.addListener(_onHwPenChanged);
_initPersistence();
_initPenConfig();
}
@@ -339,6 +348,34 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _onPenConfigChanged() {
if (mounted) setState(() {});
_syncBarrelSelectText();
}
void _onHwPenChanged() {
_syncBarrelSelectText();
}
/// Level-trigger: holding barrel with sideButton=selectText enables text
/// selection without latching the toolbar tool.
void _syncBarrelSelectText() {
final cfg = _penConfig?.value;
final hw = PenInputService.instance;
final want = cfg != null &&
hw.isActive &&
hw.current.barrel &&
cfg.sideButton == PenButtonAction.selectText;
if (want == _barrelSelectText) return;
if (!mounted) return;
setState(() {
_barrelSelectText = want;
if (want) {
_placeLinkMode = false;
_removeHighlightMode = false;
_textMode = false;
_selected = null;
_expandedSticky = null;
}
});
}
Future<void> _initPersistence() async {
@@ -412,7 +449,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
_overlayRepaint.dispose();
_liveStrokeVN.dispose();
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
PenInputService.instance.removeListener(_onHwPenChanged);
PenInputService.instance.stop();
DiagnosticLogger.instance.stop();
super.dispose();
@@ -509,6 +548,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
double? _rawNormalizedPressure(PointerEvent event) {
final hw = PenInputService.instance;
if (hw.isActive && hw.current.pressureValid) {
return hw.current.pressure.clamp(0.0, 1.0);
}
final range = event.pressureMax - event.pressureMin;
if (range > 0.0001) {
return ((event.pressure - event.pressureMin) / range).clamp(0.0, 1.0);
@@ -538,6 +581,19 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return null;
}
/// Hit-test scratch-link markers near [normalized] on [page].
ScratchLink? _hitScratchMarker(int page, Offset normalized) {
const r = 0.045;
final r2 = r * r;
for (final link in _scratchLinks) {
if (link.pageIndex != page) continue;
final dx = link.nx - normalized.dx;
final dy = link.ny - normalized.dy;
if (dx * dx + dy * dy <= r2) return link;
}
return null;
}
void _onPenEvent(PointerEvent event) {
if (_isStylus(event.kind)) _emitPenDebug(event);
@@ -545,6 +601,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
if (event is PointerDownEvent) {
if (hit == null) return;
// Stylus can open sticky markers (PenCaptureRegion otherwise steals taps).
final sticky = _hitScratchMarker(hit.page, hit.normalized);
if (sticky != null) {
_openScratchLink(sticky);
return;
}
if (_isEraser) {
_liveStrokePage = hit.page;
_eraseAt(hit.page, hit.normalized);
@@ -687,7 +749,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
color: _currentColor().toARGB32(),
width: _currentStrokeWidth(),
kind: PenStrokeKind.pen,
brush: _currentBrush(),
brush: kShapeBrush,
),
);
}
@@ -847,7 +909,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
final scaleDrop =
rawRatio > _kScaleGlitchHi || rawRatio < _kScaleGlitchLo;
final focalDrop = details.focalPointDelta.distance > _kFocalGlitchPx;
if (scaleDrop || focalDrop) return;
if (scaleDrop || focalDrop) {
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: _pinchLastAppliedScale,
appliedChange: 1.0,
focalJumpPx: details.focalPointDelta.distance,
scaleDrop: scaleDrop,
focalDrop: focalDrop,
);
return;
}
final targetScale = absolutePinchScale(
scaleStart: _pinchScaleStart!,
@@ -866,6 +939,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
duration: Duration.zero,
);
final applied =
_pinchLastAppliedScale > 0 ? targetScale / _pinchLastAppliedScale : 1.0;
InputDiagnostics.instance.recordScaleFrame(
rawScale: details.scale,
pointerCount: details.pointerCount,
currentScale: targetScale,
appliedChange: applied,
focalJumpPx: details.focalPointDelta.distance,
scaleDrop: false,
focalDrop: false,
);
_pinchLastRawScale = details.scale;
_pinchLastAppliedScale = targetScale;
}
@@ -1071,7 +1156,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _setTool(EditorToolKind tool) {
setState(() {
_tool = tool;
_selectTextMode = false;
_selectTextTool = false;
_placeLinkMode = false;
_removeHighlightMode = false;
// The TEXT tool is the one EditorToolKind that drives a page-anchored
@@ -1083,11 +1168,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _enableSelectText() {
setState(() {
_selectTextMode = true;
_selectTextTool = true;
_placeLinkMode = false;
_removeHighlightMode = false;
_textMode = false;
_selected = null;
_expandedSticky = null;
});
}
@@ -1097,9 +1183,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
setState(() {
_placeLinkMode = !_placeLinkMode;
if (_placeLinkMode) {
_selectTextMode = false;
_selectTextTool = false;
_removeHighlightMode = false;
_textMode = false;
_expandedSticky = null;
}
});
}
@@ -1110,10 +1197,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
setState(() {
_removeHighlightMode = !_removeHighlightMode;
if (_removeHighlightMode) {
_selectTextMode = false;
_selectTextTool = false;
_placeLinkMode = false;
_textMode = false;
_selected = null;
_expandedSticky = null;
}
});
}
@@ -1138,35 +1226,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
setState(() => _scratchLinks.add(link));
}
/// Open the anchor's split view (left = this PDF at the anchor page, right =
/// the anchor's private infinite scratchpad, stored in the sidecar).
///
/// Flushes any pending sidecar write FIRST so the split view (which opens its
/// own [SidecarRepository] on the same file) sees this anchor on disk before
/// it writes the scratchpad back. On return, reload so any scratchpad change
/// made there is reflected in this editor's in-memory sidecar repo.
/// Expand the sticky overlay on-page (paper sticky UX). Uses the SAME
/// [SidecarRepository] — no second open, no split-view race.
Future<void> _openScratchLink(ScratchLink link) async {
await _repo?.flush();
setState(() {
_expandedSticky = link;
_selectTextTool = false;
_placeLinkMode = false;
_removeHighlightMode = false;
_textMode = false;
});
}
void _closeSticky() {
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SplitViewScreen(
filePath: widget.pdfPath,
scratchLinkId: link.id,
initialPage: link.pageIndex,
),
),
);
if (!mounted) return;
// The split view wrote the scratchpad into the on-disk sidecar via its own
// repo; re-open ours so subsequent saves here don't clobber that scratchpad.
final repo = await SidecarRepository.open(widget.pdfPath, docType: 'pdf');
if (!mounted) {
repo.dispose();
return;
}
_repo?.dispose();
_repo = repo;
setState(() => _expandedSticky = null);
}
/// Confirm + delete an anchor (and its private scratchpad).
@@ -1694,6 +1768,32 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
child: const IgnorePointer(child: SizedBox.expand()),
),
),
if (_hasSelection)
Positioned(
left: 16,
right: 16,
bottom: 24,
child: _SelectionActionBar(
onHighlight: _highlightSelection,
onBookmark: _addBookmark,
),
),
if (_expandedSticky != null && _repo != null)
Positioned(
right: 20,
top: 72,
child: StickyNoteOverlay(
key: ValueKey(_expandedSticky!.id),
link: _expandedSticky!,
repo: _repo!,
onClose: _closeSticky,
onDelete: () async {
final link = _expandedSticky!;
_closeSticky();
await _confirmDeleteScratchLink(link);
},
),
),
];
},
),
@@ -2221,8 +2321,50 @@ class LiveStrokeOverlayHarness {
}
}
/// Floating action bar shown while PDF text is selected (OneNote-style).
class _SelectionActionBar extends StatelessWidget {
const _SelectionActionBar({
required this.onHighlight,
required this.onBookmark,
});
final VoidCallback onHighlight;
final VoidCallback onBookmark;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l = AppLocalizations.of(context);
return Center(
child: Material(
elevation: 4,
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
onPressed: onHighlight,
icon: const Icon(Icons.highlight, size: 18),
label: Text(l.actionHighlightSelection),
),
TextButton.icon(
onPressed: onBookmark,
icon: const Icon(Icons.bookmark_add_outlined, size: 18),
label: Text(l.toolAddBookmark),
),
],
),
),
),
);
}
}
/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor.
/// Tap opens the anchor's split view; long-press deletes the anchor.
/// Tap opens the sticky overlay; long-press deletes the anchor.
class _ScratchLinkMarker extends StatelessWidget {
const _ScratchLinkMarker({required this.onTap, required this.onLongPress});
@@ -2335,9 +2477,9 @@ class _TextAnnotationLabel extends StatelessWidget {
/// 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).
/// automatically. Does NOT autofocus on insert (Windows tablets otherwise pop
/// the soft keyboard on every pen tap that places a box); focus only after an
/// explicit tap on the field.
class _TextAnnotationField extends StatefulWidget {
const _TextAnnotationField({
super.key,
@@ -2370,11 +2512,6 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> {
_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() {
@@ -2399,7 +2536,7 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> {
child: TextField(
controller: _controller,
focusNode: _focusNode,
autofocus: true,
autofocus: false,
maxLines: null,
minLines: 1,
keyboardType: TextInputType.multiline,