fix: PDF finger ink, chrome UX, OneNote pens, and pen physics
Some checks failed
CI / Windows build (push) Has been cancelled

Wire finger drawing on PDF without breaking pinch; auto-hide page scrubber and fix bounce; share sticky tools with resize and per-page remember; side-button select; separate pen slots with colors; rnote pressure shapes plus tip-velocity width and lower stroke latency.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-06 16:01:01 +08:00
parent 85af037b7d
commit f31dd0fb52
17 changed files with 835 additions and 234 deletions

View File

@@ -23,6 +23,7 @@
// survive reopen, and a stored highlight can be removed (the un-highlight tool).
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'
show ValueListenable, visibleForTesting;
@@ -37,6 +38,7 @@ import '../../models/scratch_link.dart';
import '../../storage/badnote_sidecar.dart';
import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily;
import '../engine/brush.dart';
import '../engine/pen_physics.dart';
import '../engine/shape_geometry.dart';
import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
@@ -113,6 +115,26 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// dragged (like the slide editor's scrubber); null when not scrubbing.
double? _pageScrub;
/// Scrubber is opt-in (tap page label); not always on-screen.
bool _showPageScrubber = false;
/// Bottom page chrome auto-hides after idle (OneNote-like).
bool _pageChromeVisible = true;
Timer? _pageChromeHideTimer;
/// Finger-ink pointer tracking (PDF path; does NOT go through PenCaptureRegion).
final Set<int> _fingerPointers = <int>{};
bool _fingerStrokeActive = false;
Offset? _lastTipNorm;
Duration? _lastTipTime;
/// Per-page sticky that was open when the user left the page — restored on return.
final Map<int, String> _stickyRememberedByPage = <int, String>{};
/// Rising-edge tracker for hardware side-button actions on PDF.
PenButtonAction _lastHwSideAction = PenButtonAction.none;
/// Strokes per page, keyed by 0-based page index (normalized coords).
final Map<int, List<PenStroke>> _strokesByPage = {};
@@ -370,6 +392,28 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _onHwPenChanged() {
_syncBarrelSelectText();
_dispatchHwSideButton();
}
/// Rising-edge side-button → universal stroke select (default mapping).
void _dispatchHwSideButton() {
final cfg = _penConfig?.value;
final hw = PenInputService.instance;
if (cfg == null || !hw.isActive) return;
final action = hw.current.barrel ? cfg.sideButton : PenButtonAction.none;
if (action == _lastHwSideAction) return;
final prev = _lastHwSideAction;
_lastHwSideAction = action;
if (action == PenButtonAction.select && prev != PenButtonAction.select) {
_setTool(EditorToolKind.select);
} else if (action == PenButtonAction.undo && prev != PenButtonAction.undo) {
if (_undoFor(_pageIndex).canUndo) _performUndo();
} else if (action == PenButtonAction.toggleTool &&
prev != PenButtonAction.toggleTool) {
_setTool(_tool == EditorToolKind.eraser
? EditorToolKind.brush
: EditorToolKind.eraser);
}
}
/// Level-trigger: holding barrel with sideButton=selectText enables text
@@ -458,6 +502,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
@override
void dispose() {
_pageChromeHideTimer?.cancel();
// Flush any pending sidecar write before tearing down.
final repo = _repo;
if (repo != null) {
@@ -630,6 +675,71 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return null;
}
/// Brush gamma pressure, then tip-velocity physical scale (fountain thins at speed).
double? _pressureWithPhysics(PointerEvent event, Offset normalized) {
final base = _normalizedPressure(event);
if (base == null) {
_lastTipNorm = normalized;
_lastTipTime = event.timeStamp;
return null;
}
double shaped = base;
if (_lastTipNorm != null && _lastTipTime != null) {
final dt = (event.timeStamp - _lastTipTime!).inMicroseconds / 1e6;
if (dt > 0) {
final dx = normalized.dx - _lastTipNorm!.dx;
final dy = normalized.dy - _lastTipNorm!.dy;
final speed = math.sqrt(dx * dx + dy * dy) / dt;
shaped = (shaped * tipVelocityWidthScale(_currentBrush(), speed))
.clamp(0.0, 1.0);
}
}
_lastTipNorm = normalized;
_lastTipTime = event.timeStamp;
return shaped;
}
void _onFingerPointer(PointerEvent event) {
if (!_allowFingerDrawing || !_penCaptureEnabled) return;
if (event.kind != PointerDeviceKind.touch) return;
if (event is PointerDownEvent) {
_fingerPointers.add(event.pointer);
if (_fingerPointers.length >= 2) {
if (_fingerStrokeActive) {
_endStroke(commit: false);
_fingerStrokeActive = false;
setState(() {});
}
return;
}
if (!_fingerStrokeActive) {
setState(() => _fingerStrokeActive = true);
}
_onPenEvent(event);
return;
}
if (event is PointerMoveEvent) {
if (!_fingerStrokeActive || !_fingerPointers.contains(event.pointer)) {
return;
}
if (_fingerPointers.length >= 2) return;
_onPenEvent(event);
return;
}
if (event is PointerUpEvent || event is PointerCancelEvent) {
_fingerPointers.remove(event.pointer);
if (_fingerStrokeActive && _fingerPointers.isEmpty) {
_onPenEvent(event);
_fingerStrokeActive = false;
if (mounted) setState(() {});
} else if (_fingerPointers.isEmpty && _fingerStrokeActive) {
_fingerStrokeActive = false;
if (mounted) setState(() {});
}
}
}
void _onPenEvent(PointerEvent event) {
if (_isStylus(event.kind)) _emitPenDebug(event);
@@ -661,10 +771,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return;
}
_liveStrokePage = hit.page;
_lastTipNorm = null;
_lastTipTime = null;
_livePoints
..clear()
..add(PenPoint(hit.normalized.dx, hit.normalized.dy,
_normalizedPressure(event)));
_pressureWithPhysics(event, hit.normalized)));
_updateLiveStroke();
} else if (event is PointerMoveEvent) {
final page = _liveStrokePage;
@@ -689,7 +801,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// A stroke belongs to ONE page: ignore samples on a different page.
if (hit == null || hit.page != page) return;
_livePoints.add(PenPoint(
hit.normalized.dx, hit.normalized.dy, _normalizedPressure(event)));
hit.normalized.dx,
hit.normalized.dy,
_pressureWithPhysics(event, hit.normalized)));
_updateLiveStroke();
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_endStroke(commit: event is PointerUpEvent);
@@ -1273,9 +1387,57 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _goToPage(int index) {
if (_pageCount == 0) return;
final clamped = index.clamp(0, _pageCount - 1);
// Optimistic index so the pill doesn't flash the old page while pdfrx animates.
if (clamped != _pageIndex) {
setState(() {
_pageIndex = clamped;
_pageScrub = null;
});
_onPageIndexChanging(clamped);
} else {
setState(() => _pageScrub = null);
}
_bumpPageChrome();
_controller.goToPage(pageNumber: clamped + 1);
}
void _bumpPageChrome() {
_pageChromeHideTimer?.cancel();
if (!_pageChromeVisible && mounted) {
setState(() => _pageChromeVisible = true);
}
_pageChromeHideTimer = Timer(const Duration(seconds: 3), () {
if (!mounted) return;
if (_pageScrub != null || _showPageScrubber) return;
setState(() {
_pageChromeVisible = false;
_showPageScrubber = false;
});
});
}
/// Hide sticky when leaving its page; remember id so returning re-expands it.
void _onPageIndexChanging(int newIndex) {
final open = _expandedSticky;
if (open != null && open.pageIndex != newIndex) {
_stickyRememberedByPage[open.pageIndex] = open.id;
_expandedSticky = null;
}
final rememberedId = _stickyRememberedByPage[newIndex];
if (rememberedId != null && _expandedSticky == null) {
ScratchLink? link;
for (final s in _scratchLinks) {
if (s.id == rememberedId) {
link = s;
break;
}
}
if (link != null) {
_expandedSticky = link;
}
}
}
void _setTool(EditorToolKind tool) {
setState(() {
_tool = tool;
@@ -1363,7 +1525,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
void _closeSticky() {
if (!mounted) return;
setState(() => _expandedSticky = null);
final open = _expandedSticky;
setState(() {
if (open != null) {
// Explicit close: do not auto-reopen when returning to this page.
_stickyRememberedByPage.remove(open.pageIndex);
}
_expandedSticky = null;
});
}
/// Confirm + delete an anchor (and its private scratchpad).
@@ -1687,16 +1856,50 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
),
),
),
// Floating page-control pill (bottom-center).
if (_viewerReady && _pageCount > 0)
// Floating page-control pill (bottom-center). Auto-hides when idle.
if (_viewerReady && _pageCount > 0 && _pageChromeVisible)
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
padding: EdgeInsets.only(
bottom: _hasSelection ? 72 : 16,
),
child: _buildPagePill(),
),
),
)
else if (_viewerReady && _pageCount > 0)
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Material(
color: Theme.of(context)
.colorScheme
.surfaceContainerHigh
.withValues(alpha: 0.92),
elevation: 2,
borderRadius: BorderRadius.circular(20),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: _bumpPageChrome,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 6),
child: Text(
'${_pageIndex + 1} / $_pageCount',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
),
),
),
),
// Back button (top-left).
SafeArea(
@@ -1728,7 +1931,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// the zoom and snaps back. We drive zoom ourselves via the glitch-guarded
// _TwoFingerPinch recognizer in viewerOverlayBuilder → controller.
// zoomOnLocalPosition (focal zoom). See _onPinchUpdate.
panEnabled: true,
// Suppress 1-finger pan while a finger-ink stroke is active so the
// page doesn't scroll under the stroke (finger draw is opt-in).
panEnabled: !_fingerStrokeActive,
scaleEnabled: false,
// Native vector text selection. Pen falls through to this only in
// select-text mode (PenCaptureRegion.captureEnabled == false).
@@ -1742,6 +1947,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_viewerReady = true;
_pageCount = document.pages.length;
});
_bumpPageChrome();
// Honor a requested initial page (search-result jump), clamped.
final target = widget.initialPage.clamp(0, _pageCount - 1);
if (target > 0) {
@@ -1751,7 +1957,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
onPageChanged: (pageNumber) {
if (pageNumber == null || !mounted) return;
final idx = pageNumber - 1;
if (idx != _pageIndex) setState(() => _pageIndex = idx);
if (idx != _pageIndex) {
setState(() {
_onPageIndexChanging(idx);
_pageIndex = idx;
if (_pageScrub != null &&
(_pageScrub!.round() - 1) == idx) {
_pageScrub = null;
}
});
_bumpPageChrome();
}
},
// (1) Per-page overlay: committed ink + live stroke + highlights, all in
// normalized page space scaled to the on-screen page rect.
@@ -1921,6 +2137,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
child: const SizedBox.expand(),
),
),
Positioned.fill(
child: Listener(
behavior: _fingerStrokeActive
? HitTestBehavior.opaque
: HitTestBehavior.translucent,
onPointerDown: _onFingerPointer,
onPointerMove: _onFingerPointer,
onPointerUp: _onFingerPointer,
onPointerCancel: _onFingerPointer,
child: const SizedBox.expand(),
),
),
Positioned.fill(
child: PenCaptureRegion(
captureEnabled: _penCaptureEnabled,
@@ -1932,7 +2160,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
Positioned(
left: 16,
right: 16,
bottom: 24,
bottom: 88,
child: _SelectionActionBar(
onHighlight: _highlightSelection,
onBookmark: _addBookmark,
@@ -1946,6 +2174,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
key: ValueKey(_expandedSticky!.id),
link: _expandedSticky!,
repo: _repo!,
brush: _penBrush,
color: _color,
tool: _tool,
allowFingerDrawing: _allowFingerDrawing,
onClose: _closeSticky,
onDelete: () async {
final link = _expandedSticky!;
@@ -1973,17 +2205,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
BrushPickerButton(
selected: _penBrush,
active: _tool == EditorToolKind.brush && _penCaptureEnabled,
tooltip: l.brushPicker,
labelFor: (b) => brushLabel(b, l),
colorFor: (b) => _brushColors[b] ?? Colors.black,
onSelected: (b) {
setState(() => _penBrush = b);
_setTool(EditorToolKind.brush);
},
),
// OneNote-style: each pen is its own slot with remembered color.
for (final b in kPenToolBrushes)
PenSlotButton(
kind: b,
selected: _tool == EditorToolKind.brush &&
_penBrush == b &&
_penCaptureEnabled,
color: _brushColors[b] ?? Colors.black,
tooltip: brushLabel(b, l),
onPressed: () {
setState(() => _penBrush = b);
_setTool(EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
@@ -2222,7 +2457,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (total > 1)
if (total > 1 && _showPageScrubber)
Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(maxWidth: 420),
@@ -2238,10 +2473,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
value: (scrub ?? (_pageIndex + 1).toDouble())
.clamp(1, total.toDouble()),
divisions: total > 1 ? total - 1 : null,
onChanged: (v) => setState(() => _pageScrub = v),
onChanged: (v) {
setState(() => _pageScrub = v);
_bumpPageChrome();
},
onChangeEnd: (v) {
setState(() => _pageScrub = null);
_goToPage(v.round() - 1);
final target = v.round() - 1;
// Keep scrub until optimistic _goToPage clears it — no bounce.
setState(() => _pageScrub = v);
_goToPage(target);
setState(() => _showPageScrubber = false);
_bumpPageChrome();
},
),
),
@@ -2259,11 +2501,23 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
IconButton(
tooltip: l.previousPage,
icon: const Icon(Icons.chevron_left),
onPressed:
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
onPressed: _pageIndex > 0
? () {
_bumpPageChrome();
_goToPage(_pageIndex - 1);
}
: null,
),
TextButton(
onPressed: _viewerReady ? _openThumbnails : null,
onPressed: () {
_bumpPageChrome();
if (total > 1) {
setState(() => _showPageScrubber = !_showPageScrubber);
} else if (_viewerReady) {
_openThumbnails();
}
},
onLongPress: _viewerReady ? _openThumbnails : null,
child: Text(
l.pageOfPages(shown, total),
style: TextStyle(
@@ -2276,7 +2530,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
tooltip: l.nextPage,
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)
? () {
_bumpPageChrome();
_goToPage(_pageIndex + 1);
}
: null,
),
],