fix: PDF finger ink, chrome UX, OneNote pens, and pen physics
Some checks failed
CI / Windows build (push) Has been cancelled
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:
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user