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

@@ -31,7 +31,7 @@ enum PenButtonAction {
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
class PenConfig {
const PenConfig({
this.sideButton = PenButtonAction.select,
this.sideButton = PenButtonAction.selectText,
this.eraserEnd = PenButtonAction.eraser,
this.pressureGamma = kNaturalPressureGamma,
this.palmRejectionMs = 150.0,

View File

@@ -0,0 +1,213 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../engine/brush.dart';
/// OneNote-style independent pen slot: brush + color + thickness together.
///
/// Selecting a slot restores all three; color dots / thickness controls edit
/// only the active slot.
class PenSlot {
const PenSlot({
required this.id,
required this.brush,
required this.color,
required this.width,
});
final String id;
/// Brush kind for this slot (fountain / ballpoint / pencil — not highlighter).
final BrushKind brush;
final Color color;
/// Stroke width as a fraction of page width.
final double width;
PenSlot copyWith({
String? id,
BrushKind? brush,
Color? color,
double? width,
}) {
return PenSlot(
id: id ?? this.id,
brush: brush ?? this.brush,
color: color ?? this.color,
width: width ?? this.width,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'brush': brush.name,
'color': color.toARGB32(),
'width': width,
};
factory PenSlot.fromJson(Map<String, dynamic> json) {
final brushName = json['brush'] as String? ?? '';
return PenSlot(
id: json['id'] as String? ?? 'slot_0',
brush: BrushKind.values.asNameMap()[brushName] ?? BrushKind.fountainPen,
color: Color(json['color'] as int? ?? 0xFF000000),
width: (json['width'] as num?)?.toDouble() ?? 0.006,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PenSlot &&
runtimeType == other.runtimeType &&
id == other.id &&
brush == other.brush &&
color.toARGB32() == other.color.toARGB32() &&
width == other.width;
@override
int get hashCode => Object.hash(id, brush, color.toARGB32(), width);
}
/// Default pen slots seeded OneNote-style (fountain / ballpoint / pencil).
List<PenSlot> kDefaultPenSlots() => const [
PenSlot(
id: 'slot_0',
brush: BrushKind.fountainPen,
color: Colors.black,
width: 0.006,
),
PenSlot(
id: 'slot_1',
brush: BrushKind.ballpoint,
color: Colors.blue,
width: 0.0022,
),
PenSlot(
id: 'slot_2',
brush: BrushKind.pencil,
color: Colors.green,
width: 0.003,
),
];
/// S / M / L thickness presets (page-width fractions) for the toolbar picker.
const double kThicknessSmall = 0.0022;
const double kThicknessMedium = 0.006;
const double kThicknessLarge = 0.012;
/// Allowed range for slot stroke width (page-width fraction).
const double kPenSlotWidthMin = 0.001;
const double kPenSlotWidthMax = 0.05;
/// Manages independent [PenSlot]s with SharedPreferences persistence.
///
/// Load with [PenSlotsController.load], then listen via [ChangeNotifier].
class PenSlotsController extends ChangeNotifier {
PenSlotsController._(this._prefs, this._slots, this._activeId);
/// SharedPreferences key for the slots JSON blob.
static const prefsKey = 'pen_slots_v1';
final SharedPreferences _prefs;
List<PenSlot> _slots;
String _activeId;
List<PenSlot> get slots => List.unmodifiable(_slots);
String get activeId => _activeId;
PenSlot get active {
for (final s in _slots) {
if (s.id == _activeId) return s;
}
return _slots.first;
}
/// Loads persisted slots, or seeds [kDefaultPenSlots] on first run / corrupt
/// JSON.
static Future<PenSlotsController> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(prefsKey);
var slots = kDefaultPenSlots();
var activeId = slots.first.id;
if (raw != null) {
try {
final map = jsonDecode(raw) as Map<String, dynamic>;
final list = map['slots'] as List<dynamic>?;
if (list != null && list.isNotEmpty) {
slots = [
for (final e in list)
PenSlot.fromJson(e as Map<String, dynamic>),
];
}
final storedActive = map['activeId'] as String?;
if (storedActive != null &&
slots.any((s) => s.id == storedActive)) {
activeId = storedActive;
} else {
activeId = slots.first.id;
}
} catch (_) {
slots = kDefaultPenSlots();
activeId = slots.first.id;
}
}
return PenSlotsController._(prefs, slots, activeId);
}
Future<void> _persist() async {
await _prefs.setString(
prefsKey,
jsonEncode({
'activeId': _activeId,
'slots': [for (final s in _slots) s.toJson()],
}),
);
}
int _indexOfActive() {
final i = _slots.indexWhere((s) => s.id == _activeId);
return i >= 0 ? i : 0;
}
void _replaceActive(PenSlot next) {
final i = _indexOfActive();
_slots = [..._slots]..[i] = next;
}
/// Selects [id] as the active slot (restores brush + color + width).
Future<void> select(String id) async {
if (!_slots.any((s) => s.id == id)) return;
if (_activeId == id) return;
_activeId = id;
notifyListeners();
await _persist();
}
/// Sets the active slot's color.
Future<void> setActiveColor(Color c) async {
_replaceActive(active.copyWith(color: c));
notifyListeners();
await _persist();
}
/// Sets the active slot's stroke width (clamped).
Future<void> setActiveWidth(double w) async {
final clamped = w.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
_replaceActive(active.copyWith(width: clamped));
notifyListeners();
await _persist();
}
/// Sets the active slot's brush kind.
Future<void> setActiveBrush(BrushKind b) async {
if (b == BrushKind.highlighter) return;
_replaceActive(active.copyWith(brush: b));
notifyListeners();
await _persist();
}
}