fix: restore PDF pen capture and overhaul sticky/pens/pages
All checks were successful
CI / Windows build (push) Successful in 9m55s

Reinstall PenCaptureBinding so stylus ink hits again; keep finger Listener translucent under pinch; page-anchor sticky with drag/resize; OneNote pen slots (brush+width+color); blank-note multi-page; default side button to hold-select-text.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 02:38:58 +08:00
parent ad9b1b46db
commit 307161f465
16 changed files with 1279 additions and 487 deletions

View File

@@ -48,6 +48,7 @@ import '../engine/undo_stack.dart';
import '../input/diagnostic_logger.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pen_slots.dart';
import '../input/pressure_curve.dart'
show PressureCurve, kNaturalPressureFloor;
import '../pdf/pen_capture_region.dart';
@@ -166,6 +167,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
/// Loaded asynchronously in initState; null until ready.
PenConfigController? _penConfig;
/// Independent OneNote-style pen slots (brush + color + width per slot).
PenSlotsController? _penSlots;
// ── Persistence ────────────────────────────────────────────────────────────
/// Per-file sidecar persistence. Identity is the source file PATH (the
@@ -195,7 +199,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
static const double _kScaleGlitchHi = 1.18;
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
static const double _kFocalGlitchPx = 100.0;
static const double _kFocalGlitchPx = 64.0;
/// Matrix scale captured at the current baseline (gesture start or the last
/// pointer-count re-baseline). Null when no pinch is active.
@@ -250,11 +254,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// path (they disable pen capture), so they stay as their own booleans.
EditorToolKind _tool = EditorToolKind.brush;
/// Selected brush for the BRUSH tool (fountain/ballpoint/pencil). The
/// highlighter tool always uses [BrushKind.highlighter]. Local state only for
/// this increment (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
@@ -270,22 +269,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
Offset? _selectLast;
bool _selectDragging = false;
/// rnote-style per-brush color memory: each brush (and the highlighter)
/// remembers its own color. Selecting a brush restores its color; picking a
/// color updates ONLY the active brush's entry. In-memory only for this
/// increment (TODO(brush-color-persist)).
final Map<BrushKind, Color> _brushColors = {
BrushKind.fountainPen: Colors.black,
BrushKind.ballpoint: Colors.blue,
BrushKind.pencil: Colors.green,
BrushKind.highlighter: Colors.orange,
};
/// Highlighter keeps its own color (not a pen slot).
Color _highlighterColor = Colors.orange;
/// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter,
/// else the selected pen brush).
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
/// Active pen brush from the selected slot (fallback until slots load).
BrushKind get _penBrush =>
_penSlots?.active.brush ?? BrushKind.fountainPen;
/// When true the "select text" tool button is latched on.
bool _selectTextTool = false;
@@ -327,16 +316,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
static const _uuid = Uuid();
/// The active drawing color = the active brush's remembered color.
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
/// The active drawing color: highlighter tool uses [_highlighterColor], else
/// the active pen slot's color.
Color get _color => _tool == EditorToolKind.highlighter
? _highlighterColor
: (_penSlots?.active.color ?? Colors.black);
bool _allowFingerDrawing = false;
/// Whether the viewer currently has a non-empty text selection (drives the
/// "highlight selection" action's enabled state).
bool _hasSelection = false;
/// Pen width as a fraction of page width (base; pressure thins it down).
static const double _penWidthFraction = 0.006;
/// Fallback highlighter width as a fraction of page width.
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = kInkPalette;
@@ -348,8 +339,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
!_selectTextMode &&
!_placeLinkMode &&
!_removeHighlightMode &&
!_textMode &&
_expandedSticky == null;
!_textMode;
/// True when the eraser tool is active.
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
@@ -373,15 +363,23 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
Future<void> _initPenConfig() async {
final controller = await PenConfigController.load();
final results = await Future.wait([
PenConfigController.load(),
PenSlotsController.load(),
]);
final config = results[0] as PenConfigController;
final slots = results[1] as PenSlotsController;
if (!mounted) {
controller.dispose();
config.dispose();
slots.dispose();
return;
}
controller.addListener(_onPenConfigChanged);
config.addListener(_onPenConfigChanged);
slots.addListener(_onPenSlotsChanged);
setState(() {
_penConfig = controller;
_allowFingerDrawing = controller.value.fingerDrawing;
_penConfig = config;
_penSlots = slots;
_allowFingerDrawing = config.value.fingerDrawing;
});
}
@@ -390,6 +388,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_syncBarrelSelectText();
}
void _onPenSlotsChanged() {
if (mounted) setState(() {});
}
void _onHwPenChanged() {
_syncBarrelSelectText();
_dispatchHwSideButton();
@@ -513,6 +515,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
_liveStrokeVN.dispose();
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_penSlots?.removeListener(_onPenSlotsChanged);
_penSlots?.dispose();
PenInputService.instance.removeListener(_onHwPenChanged);
PenInputService.instance.stop();
DiagnosticLogger.instance.stop();
@@ -700,7 +704,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
void _onFingerPointer(PointerEvent event) {
if (!_allowFingerDrawing || !_penCaptureEnabled) return;
// Finger ink is independent of sticky expand; still blocked in text-select modes.
if (!_allowFingerDrawing) return;
if (_selectTextMode || _placeLinkMode || _removeHighlightMode || _textMode) {
return;
}
if (event.kind != PointerDeviceKind.touch) return;
if (event is PointerDownEvent) {
@@ -995,7 +1003,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
double _currentStrokeWidth() => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
: (_penSlots?.active.width ?? 0.006);
PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter
? PenStrokeKind.highlighter
@@ -1523,6 +1531,16 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
});
}
void _updateScratchLink(ScratchLink next) {
final i = _scratchLinks.indexWhere((s) => s.id == next.id);
if (i < 0) return;
setState(() {
_scratchLinks[i] = next;
if (_expandedSticky?.id == next.id) _expandedSticky = next;
});
_repo?.scheduleScratchLinkUpsert(next);
}
void _closeSticky() {
if (!mounted) return;
final open = _expandedSticky;
@@ -2099,14 +2117,59 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// delete. Sized in screen px so the tap target stays usable at any
// zoom; positioned at (nx*pageW, ny*pageH).
for (final link in linksOnPage)
if (_expandedSticky?.id != link.id)
Positioned(
left: link.nx * pageW - _kMarkerSize / 2,
top: link.ny * pageH - _kMarkerSize / 2,
width: _kMarkerSize,
height: _kMarkerSize,
child: _ScratchLinkMarker(
onTap: () => _openScratchLink(link),
onLongPress: () => _confirmDeleteScratchLink(link),
),
),
// Expanded sticky glued to page (moves/scales with PDF zoom).
if (_expandedSticky != null &&
_repo != null &&
_expandedSticky!.pageIndex == pageIndex)
Positioned(
left: link.nx * pageW - _kMarkerSize / 2,
top: link.ny * pageH - _kMarkerSize / 2,
width: _kMarkerSize,
height: _kMarkerSize,
child: _ScratchLinkMarker(
onTap: () => _openScratchLink(link),
onLongPress: () => _confirmDeleteScratchLink(link),
left: (_expandedSticky!.nx * pageW).clamp(0.0, pageW * 0.85),
top: (_expandedSticky!.ny * pageH).clamp(0.0, pageH * 0.85),
width: (_expandedSticky!.nw * pageW).clamp(120.0, pageW),
height: (_expandedSticky!.nh * pageH).clamp(140.0, pageH),
child: StickyNoteOverlay(
key: ValueKey(_expandedSticky!.id),
link: _expandedSticky!,
repo: _repo!,
brush: _penBrush,
color: _color,
tool: _tool,
strokeWidth: _penSlots?.active.width ?? 0.006,
allowFingerDrawing: _allowFingerDrawing,
onClose: _closeSticky,
onDelete: () async {
final link = _expandedSticky!;
_closeSticky();
await _confirmDeleteScratchLink(link);
},
onDragPx: (dx, dy) {
final link = _expandedSticky;
if (link == null || pageW <= 0 || pageH <= 0) return;
final next = link.copyWith(
nx: (link.nx + dx / pageW).clamp(0.0, 0.95),
ny: (link.ny + dy / pageH).clamp(0.0, 0.95),
);
_updateScratchLink(next);
},
onResizePx: (dx, dy) {
final link = _expandedSticky;
if (link == null || pageW <= 0 || pageH <= 0) return;
final next = link.copyWith(
nw: (link.nw + dx / pageW).clamp(0.18, 0.95),
nh: (link.nh + dy / pageH).clamp(0.18, 0.95),
);
_updateScratchLink(next);
},
),
),
];
@@ -2119,10 +2182,21 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
// outside the arena by PenCaptureRegion) is never stolen.
viewerOverlayBuilder: (context, size, handleLinkTap) {
return [
// Finger ink FIRST (under pinch). Always translucent so 2nd finger
// still reaches the pinch recognizer above — opaque stole pinch.
Positioned.fill(
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: _onFingerPointer,
onPointerMove: _onFingerPointer,
onPointerUp: _onFingerPointer,
onPointerCancel: _onFingerPointer,
child: const SizedBox.expand(),
),
),
// Pinch on TOP of finger listener (Stack hit-tests last child first).
Positioned.fill(
child: RawGestureDetector(
// translucent (NOT opaque): the touch must ALSO hit-test pdfrx
// underneath so its pan recognizer can win the 1-finger case.
behavior: HitTestBehavior.translucent,
gestures: {
_TwoFingerPinch:
@@ -2137,18 +2211,6 @@ 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,
@@ -2166,26 +2228,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
onBookmark: _addBookmark,
),
),
if (_expandedSticky != null && _repo != null)
Positioned(
right: 20,
top: 72,
child: StickyNoteOverlay(
key: ValueKey(_expandedSticky!.id),
link: _expandedSticky!,
repo: _repo!,
brush: _penBrush,
color: _color,
tool: _tool,
allowFingerDrawing: _allowFingerDrawing,
onClose: _closeSticky,
onDelete: () async {
final link = _expandedSticky!;
_closeSticky();
await _confirmDeleteScratchLink(link);
},
),
),
// Sticky is rendered in pageOverlaysBuilder (page-anchored).
];
},
),
@@ -2205,20 +2248,25 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// OneNote-style: each pen is its own slot with remembered color.
for (final b in kPenToolBrushes)
// OneNote-style: each pen slot restores brush + color + thickness.
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
PenSlotButton(
kind: b,
kind: slot.brush,
selected: _tool == EditorToolKind.brush &&
_penBrush == b &&
(_penSlots?.activeId ?? 'slot_0') == slot.id &&
_penCaptureEnabled,
color: _brushColors[b] ?? Colors.black,
tooltip: brushLabel(b, l),
color: slot.color,
widthHint: slot.width,
tooltip: brushLabel(slot.brush, l),
onPressed: () {
setState(() => _penBrush = b);
_penSlots?.select(slot.id);
_setTool(EditorToolKind.brush);
},
),
ThicknessPickerButton(
width: _penSlots?.active.width ?? 0.006,
onChanged: (w) => _penSlots?.setActiveWidth(w),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
@@ -2323,6 +2371,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
ThicknessPickerButton(
width: _penSlots?.active.width ?? 0.006,
onChanged: (w) => _penSlots?.setActiveWidth(w),
),
PaletteDivider(cs: cs),
ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
@@ -2423,11 +2475,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
}
Widget _colorDot(Color c, ColorScheme cs) {
// Selected against the ACTIVE brush's remembered color; a tap updates only
// that brush's entry (rnote per-brush color memory). Inert in select mode.
final selected = _color == c && !_isSelect;
// Selected against the ACTIVE slot (or highlighter) color; a tap updates
// only that entry. Inert highlight in select mode.
final selected = _color.toARGB32() == c.toARGB32() && !_isSelect;
return GestureDetector(
onTap: () => setState(() => _brushColors[_activeColorBrush] = c),
onTap: () {
if (_tool == EditorToolKind.highlighter) {
setState(() => _highlighterColor = c);
} else {
_penSlots?.setActiveColor(c);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 28,