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

@@ -85,6 +85,8 @@ class PenCanvas extends StatefulWidget {
this.allowFingerDrawing = false,
this.minScale = 0.5,
this.maxScale = 8.0,
this.scaleEnabled = true,
this.panEnabled = true,
this.onPenDebug,
this.thinning = kDefaultPenThinning,
this.pressureGamma = kNaturalPressureGamma,
@@ -163,6 +165,13 @@ class PenCanvas extends StatefulWidget {
final double minScale;
final double maxScale;
/// When false, the canvas cannot be pinch-zoomed (sticky notes lock this so
/// writing isn't fighting an inner transform).
final bool scaleEnabled;
/// When false, one-finger pan is disabled (sticky notes often lock pan too).
final bool panEnabled;
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
final double thinning;
@@ -804,7 +813,7 @@ class _PenCanvasState extends State<PenCanvas> {
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
// so a pinch re-enables pan/zoom immediately.
final panEnabled = _drawPointer == null;
final panEnabled = widget.panEnabled && _drawPointer == null;
// Mirror committed strokes into the revision-tracked store (only re-mirrors
// when the parent handed us a new list identity).
@@ -823,7 +832,7 @@ class _PenCanvasState extends State<PenCanvas> {
minScale: widget.minScale,
maxScale: widget.maxScale,
panEnabled: panEnabled,
scaleEnabled: true,
scaleEnabled: widget.scaleEnabled,
child: SizedBox(
width: widget.pageSize.width,
height: widget.pageSize.height,

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,

View File

@@ -19,6 +19,7 @@ import '../engine/stroke_model.dart';
import '../persistence/sidecar_repository.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pen_slots.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../notebook/ink_stroke_adapter.dart';
@@ -42,47 +43,42 @@ class PenNoteScreen extends ConsumerStatefulWidget {
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
static const _uuid = Uuid();
/// Live strokes in normalized coords (the canvas source of truth). Persisted
/// back to the note as InkStroke via the adapter on save.
List<PenStroke> _strokes = const [];
/// Per-page live strokes in normalized coords (the canvas source of truth).
final Map<int, List<PenStroke>> _strokesByPage = {};
/// Snapshot-before-change undo/redo of the stroke list.
/// Current page index (0-based) and total page count (min 1).
int _pageIndex = 0;
int _pageCount = 1;
/// Snapshot-before-change undo/redo scoped to the current page. Cleared on
/// page switch so undo never crosses pages.
final List<List<PenStroke>> _undo = [];
final List<List<PenStroke>> _redo = [];
bool _showPageScrubber = false;
double? _pageScrub;
/// The single active-tool state (shared model across the 3 editors).
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 — see TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// 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: the highlighter when the
/// highlighter tool is active, otherwise 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;
/// The active drawing color (the active brush's remembered color).
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
/// 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);
/// The page-background template painted behind the ink (rnote-style). Default
/// blank; persisted per-notebook in the sidecar.
@@ -96,23 +92,28 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
/// Persistence flows through this note's `notebook.badnote.json` sidecar.
String? _notePath;
/// Per-file sidecar persistence sink (strokes page 0 + title), debounced and
/// atomic — replaces the old SQLite Note/noteListProvider write path here.
/// Per-file sidecar persistence sink (strokes + pageCount + title), debounced
/// and atomic — replaces the old SQLite Note/noteListProvider write path here.
SidecarRepository? _repo;
/// Page index a standalone note's strokes live under in the sidecar.
static const int _notePageIndex = 0;
final TextEditingController _titleController = TextEditingController();
PenConfigController? _penConfig;
PenSlotsController? _penSlots;
final TransformationController _transform = TransformationController();
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const List<Color> _palette = kInkPalette;
/// Current page's stroke list (PenCanvas source of truth).
List<PenStroke> get _strokes =>
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
set _strokes(List<PenStroke> value) {
_strokesByPage[_pageIndex] = value;
}
@override
void initState() {
super.initState();
@@ -123,7 +124,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
_titleController.text = note.title;
// Seed from the in-memory note's strokes (e.g. tests) until the sidecar
// load resolves and (if present) overrides with persisted strokes.
_strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage);
_strokesByPage[0] = penStrokesFromInk(note.strokes, kNoteLogicalPage);
} else {
_titleController.text = 'Untitled';
}
@@ -131,9 +132,8 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
if (_notePath != null) _initPersistence(_notePath!);
}
/// Open the note's `notebook.badnote.json` sidecar and, if it holds persisted
/// strokes / a title, hydrate the canvas from them. Strokes load as page-0
/// [EditorStroke]s converted to [PenStroke] (mirrors the PDF editor).
/// Open the note's `notebook.badnote.json` sidecar and hydrate every page of
/// strokes plus title / background / pageCount.
Future<void> _initPersistence(String notePath) async {
final repo = await SidecarRepository.open(notePath, docType: 'notebook');
if (!mounted) {
@@ -141,19 +141,47 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
return;
}
_repo = repo;
final loaded = repo.loadedStrokes[_notePageIndex];
setState(() {
if (loaded != null && loaded.isNotEmpty) {
_strokes = [for (final es in loaded) _penStrokeFromEditor(es)];
}
final title = repo.loadedTitle;
if (title != null && title.isNotEmpty) {
_titleController.text = title;
}
_background = noteBackgroundFromName(repo.loadedBackground);
_hydrateFromRepo(repo);
});
}
/// Load all pages from [repo]. pageCount = max(sidecar.pageCount ?? 1,
/// highest stroke key + 1). Persists pageCount when the sidecar omitted it.
void _hydrateFromRepo(SidecarRepository repo) {
// Only replace in-memory strokes when the sidecar actually holds ink —
// otherwise keep the seed from widget.note (widget tests / cold open).
if (repo.loadedStrokes.isNotEmpty) {
_strokesByPage.clear();
for (final entry in repo.loadedStrokes.entries) {
if (entry.value.isEmpty) continue;
_strokesByPage[entry.key] = [
for (final es in entry.value) _penStrokeFromEditor(es),
];
}
}
final fromKeys = _strokesByPage.isEmpty
? 1
: _strokesByPage.keys.reduce((a, b) => a > b ? a : b) + 1;
final declared = repo.sidecar.pageCount ?? 1;
_pageCount = declared > fromKeys ? declared : fromKeys;
if (_pageCount < 1) _pageCount = 1;
if (_pageIndex >= _pageCount) _pageIndex = _pageCount - 1;
_undo.clear();
_redo.clear();
_selectedStroke = null;
final title = repo.loadedTitle;
if (title != null && title.isNotEmpty) {
_titleController.text = title;
}
_background = noteBackgroundFromName(repo.loadedBackground);
if (repo.sidecar.pageCount != _pageCount) {
repo.schedulePageCountSave(_pageCount);
}
}
/// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush
/// is persisted on the EditorStroke now, so carry it through; old sidecars
/// without the field decode to fountainPen (back-compat default).
@@ -170,15 +198,23 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
);
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;
});
}
@@ -186,6 +222,10 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
if (mounted) setState(() {});
}
void _onPenSlotsChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
// Flush any pending sidecar write before tearing down (atomic write
@@ -197,6 +237,8 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
}
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_penSlots?.removeListener(_onPenSlotsChanged);
_penSlots?.dispose();
_titleController.dispose();
_transform.dispose();
super.dispose();
@@ -256,11 +298,60 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
// ── Persistence ──────────────────────────────────────────────────────────────
/// Persist the live pen strokes + title to the note's `notebook.badnote.json`
/// sidecar (strokes as page-0 [EditorStroke]s; title via the sidecar's title
/// field), debounced/atomic via [SidecarRepository]. Creates the notebook
/// folder lazily on first save when the screen was opened without a path.
/// Refreshes the home list and triggers local OCR for search indexing.
/// Schedule a stroke save for [pageIndex] (defaults to current) without
/// flushing. Used when switching pages so ink isn't lost mid-edit.
void _schedulePageStrokeSave([int? pageIndex]) {
final repo = _repo;
if (repo == null) return;
final idx = pageIndex ?? _pageIndex;
final pageStrokes = _strokesByPage[idx] ?? const <PenStroke>[];
final editorStrokes = <EditorStroke>[
for (final s in pageStrokes) EditorStroke.fromPenStroke(s),
];
repo.scheduleStrokeSave(idx, editorStrokes);
}
void _goToPage(int index) {
if (_pageCount < 1) return;
final clamped = index.clamp(0, _pageCount - 1);
if (clamped == _pageIndex) {
setState(() {
_pageScrub = null;
_showPageScrubber = false;
});
return;
}
_schedulePageStrokeSave(_pageIndex);
setState(() {
_pageIndex = clamped;
_pageScrub = null;
_showPageScrubber = false;
_selectedStroke = null;
_undo.clear();
_redo.clear();
});
}
void _addPage() {
_schedulePageStrokeSave(_pageIndex);
setState(() {
_pageCount += 1;
_pageIndex = _pageCount - 1;
_strokesByPage.putIfAbsent(_pageIndex, () => <PenStroke>[]);
_pageScrub = null;
_showPageScrubber = false;
_selectedStroke = null;
_undo.clear();
_redo.clear();
_dirty = true;
});
_repo?.schedulePageCountSave(_pageCount);
}
/// Persist the live pen strokes + title + pageCount to the note's
/// `notebook.badnote.json` sidecar, debounced/atomic via [SidecarRepository].
/// Creates the notebook folder lazily on first save when the screen was opened
/// without a path. Refreshes the home list and triggers local OCR for search.
Future<void> _save() async {
if (!_dirty) return;
final notifier = ref.read(noteListProvider.notifier);
@@ -284,12 +375,16 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
}
final repo = _repo!;
final editorStrokes = <EditorStroke>[
for (final s in _strokes) EditorStroke.fromPenStroke(s),
];
repo.scheduleTitleSave(title);
repo.scheduleBackgroundSave(_background.name);
repo.scheduleStrokeSave(_notePageIndex, editorStrokes);
repo.schedulePageCountSave(_pageCount);
// Persist every page that has (or had) strokes in this session. Empty pages
// clear their sidecar entry via scheduleStrokeSave.
for (final idx in _strokesByPage.keys.toList()..sort()) {
_schedulePageStrokeSave(idx);
}
// Also ensure the current page is written even if never putIfAbsent'd empty.
_schedulePageStrokeSave(_pageIndex);
await repo.flush();
// Refresh the home list so the title/recency update is visible on return.
@@ -297,10 +392,12 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
if (!mounted) return;
setState(() => _dirty = false);
// Build an in-memory Note (id = note path) for OCR/FTS indexing only.
// Build an in-memory Note (id = note path) for OCR/FTS indexing only
// flatten all pages into one stroke list.
final inkStrokes = <InkStroke>[
for (final s in _strokes)
inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
for (final page in _strokesByPage.values)
for (final s in page)
inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
];
_runLocalOcr(Note(
id: _notePath!,
@@ -341,7 +438,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
double get _strokeWidth => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
: (_penSlots?.active.width ?? 0.006);
// ── SELECT tool: select / move / delete (reuses the undo stacks) ─────────────
@@ -416,13 +513,20 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
),
),
),
// Title pill (bottom-center).
// Title + page chrome (bottom-center).
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _buildTitlePill(cs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildPagePill(cs),
const SizedBox(height: 8),
_buildTitlePill(cs),
],
),
),
),
),
@@ -520,160 +624,175 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 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,
color: _brushColors[b] ?? Colors.black,
tooltip: brushLabelEn(b),
onPressed: () => setState(() {
_penBrush = b;
_tool = EditorToolKind.brush;
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// OneNote-style: each pen slot restores brush + color + thickness.
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
PenSlotButton(
kind: slot.brush,
selected: _tool == EditorToolKind.brush &&
(_penSlots?.activeId ?? 'slot_0') == slot.id,
color: slot.color,
widthHint: slot.width,
tooltip: brushLabelEn(slot.brush),
onPressed: () {
_penSlots?.select(slot.id);
setState(() => _tool = EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter,
tooltip: 'Highlighter',
onPressed: () =>
setState(() => _tool = EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == EditorToolKind.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
),
// Select (cursor) + shape tools.
ToolButton(
icon: Icons.ads_click,
selected: _tool == EditorToolKind.select,
tooltip: 'Select',
onPressed: () => setState(() => _tool = EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _tool == EditorToolKind.shape,
tooltip: 'Shape',
labelFor: shapeLabelEn,
onActivate: () => setState(() => _tool = EditorToolKind.shape),
onSelected: (s) => setState(() {
_shapeKind = s;
_tool = EditorToolKind.shape;
}),
),
ToolButton(
icon: Icons.brush_outlined,
selected: _tool == EditorToolKind.highlighter,
tooltip: 'Highlighter',
onPressed: () =>
setState(() => _tool = EditorToolKind.highlighter),
),
ToolButton(
icon: Icons.cleaning_services_outlined,
selected: _tool == EditorToolKind.eraser,
tooltip: 'Eraser',
onPressed: () => setState(() => _tool = EditorToolKind.eraser),
),
// Select (cursor) + shape tools.
ToolButton(
icon: Icons.ads_click,
selected: _tool == EditorToolKind.select,
tooltip: 'Select',
onPressed: () => setState(() => _tool = EditorToolKind.select),
),
ShapePickerButton(
selected: _shapeKind,
active: _tool == EditorToolKind.shape,
tooltip: 'Shape',
labelFor: shapeLabelEn,
onActivate: () => setState(() => _tool = EditorToolKind.shape),
onSelected: (s) => setState(() {
_shapeKind = s;
_tool = EditorToolKind.shape;
}),
),
if (_tool == EditorToolKind.select && _selectedStroke != null)
if (_tool == EditorToolKind.select && _selectedStroke != null)
ToolButton(
icon: Icons.delete_outline,
selected: false,
tooltip: 'Delete selection',
onPressed: _deleteSelected,
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.delete_outline,
icon: Icons.undo,
selected: false,
tooltip: 'Delete selection',
onPressed: _deleteSelected,
tooltip: 'Undo',
onPressed: _undo.isNotEmpty ? _performUndo : null,
),
PaletteDivider(cs: cs),
ToolButton(
icon: Icons.undo,
selected: false,
tooltip: 'Undo',
onPressed: _undo.isNotEmpty ? _performUndo : null,
),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed: _redo.isNotEmpty ? _performRedo : null,
),
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
PaletteDivider(cs: cs),
// Page-background template picker (rnote-style: blank / dots / ruled
// / grid / cornell). Persists per-notebook in the sidecar.
PopupMenuButton<NoteBackground>(
tooltip: 'Page background',
initialValue: _background,
onSelected: (b) {
setState(() {
_background = b;
_dirty = true;
});
},
itemBuilder: (context) => [
for (final b in NoteBackground.values)
PopupMenuItem<NoteBackground>(
value: b,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(noteBackgroundIcon(b), size: 20),
const SizedBox(width: 10),
Text(noteBackgroundLabel(b)),
if (b == _background) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
ToolButton(
icon: Icons.redo,
selected: false,
tooltip: 'Redo',
onPressed: _redo.isNotEmpty ? _performRedo : null,
),
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),
// Page-background template picker (rnote-style: blank / dots / ruled
// / grid / cornell). Persists per-notebook in the sidecar.
PopupMenuButton<NoteBackground>(
tooltip: 'Page background',
initialValue: _background,
onSelected: (b) {
setState(() {
_background = b;
_dirty = true;
});
},
itemBuilder: (context) => [
for (final b in NoteBackground.values)
PopupMenuItem<NoteBackground>(
value: b,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(noteBackgroundIcon(b), size: 20),
const SizedBox(width: 10),
Text(noteBackgroundLabel(b)),
if (b == _background) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
],
),
),
],
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
noteBackgroundIcon(_background),
size: 22,
color: cs.onSurfaceVariant,
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: cs.onSurfaceVariant,
),
],
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
noteBackgroundIcon(_background),
size: 22,
color: cs.onSurfaceVariant,
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: cs.onSurfaceVariant,
),
],
),
),
),
PaletteDivider(cs: cs),
ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
onPressed: _toggleFingerDrawing,
),
ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: 'Pen settings (width, pressure, eraser…)',
onPressed: _penConfig != null
? () => showPenSettingsSheet(context, _penConfig!)
: null,
),
],
PaletteDivider(cs: cs),
ToolButton(
icon:
_allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,
tooltip: _allowFingerDrawing
? 'Finger drawing ON'
: 'Finger drawing OFF (pen only)',
onPressed: _toggleFingerDrawing,
),
ToolButton(
icon: Icons.settings_outlined,
selected: false,
tooltip: 'Pen settings (width, pressure, eraser…)',
onPressed: _penConfig != null
? () => showPenSettingsSheet(context, _penConfig!)
: null,
),
],
),
),
),
);
}
Widget _colorDot(Color c, ColorScheme cs) {
// Selected against the ACTIVE brush's remembered color. A color tap updates
// only that brush's entry (rnote per-brush color memory).
// Selected against the ACTIVE slot (or highlighter) color. A color tap
// updates only the active slot / highlighter — not other slots.
final selected = _color.toARGB32() == c.toARGB32() &&
_tool != EditorToolKind.eraser &&
_tool != EditorToolKind.select;
return GestureDetector(
onTap: () => setState(() {
onTap: () {
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
_tool = EditorToolKind.brush;
setState(() => _tool = EditorToolKind.brush);
}
_brushColors[_activeColorBrush] = c;
}),
if (_tool == EditorToolKind.highlighter) {
setState(() => _highlighterColor = c);
} else {
_penSlots?.setActiveColor(c);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
@@ -715,4 +834,88 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
),
);
}
/// Compact page chrome: prev / "n / total" / next, plus add-page. Tapping the
/// center label toggles a scrubber Slider when there is more than one page.
Widget _buildPagePill(ColorScheme cs) {
final total = _pageCount;
final scrub = _pageScrub;
final shown = (scrub ?? (_pageIndex + 1).toDouble()).round();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (total > 1 && _showPageScrubber)
Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(maxWidth: 420),
child: Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Slider(
min: 1,
max: total.toDouble(),
value: (scrub ?? (_pageIndex + 1).toDouble())
.clamp(1, total.toDouble()),
divisions: total > 1 ? total - 1 : null,
onChanged: (v) => setState(() => _pageScrub = v),
onChangeEnd: (v) {
setState(() => _pageScrub = v);
_goToPage(v.round() - 1);
},
),
),
),
),
Material(
color: cs.surfaceContainerHigh,
elevation: 3,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Previous page',
icon: const Icon(Icons.chevron_left),
onPressed: _pageIndex > 0
? () => _goToPage(_pageIndex - 1)
: null,
),
TextButton(
onPressed: () {
if (total > 1) {
setState(() => _showPageScrubber = !_showPageScrubber);
}
},
child: Text(
'$shown / $total',
style: TextStyle(
color: cs.onSurface,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
tooltip: 'Next page',
icon: const Icon(Icons.chevron_right),
onPressed: _pageIndex < total - 1
? () => _goToPage(_pageIndex + 1)
: null,
),
IconButton(
tooltip: 'Add page',
icon: const Icon(Icons.add),
onPressed: _addPage,
),
],
),
),
),
],
);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../engine/brush.dart';
import '../input/pen_slots.dart';
import 'editor_tool.dart';
/// Shared ink color palette for PDF / note / slide / scratch editors.
@@ -87,8 +88,8 @@ IconData shapeIcon(ShapeKind kind) => switch (kind) {
ShapeKind.arrow => Icons.arrow_outward,
};
/// OneNote-style pen slot: each brush is its own toolbar button with a color
/// underline (per-brush remembered color). Prefer this over [BrushPickerButton]
/// OneNote-style pen slot: each slot is its own toolbar button with a color
/// underline (slot-remembered color). Prefer this over [BrushPickerButton]
/// when the UX wants pens visible side-by-side.
class PenSlotButton extends StatelessWidget {
const PenSlotButton({
@@ -98,6 +99,7 @@ class PenSlotButton extends StatelessWidget {
required this.color,
required this.tooltip,
required this.onPressed,
this.widthHint,
});
final BrushKind kind;
@@ -106,11 +108,18 @@ class PenSlotButton extends StatelessWidget {
final String tooltip;
final VoidCallback onPressed;
/// Optional page-width fraction; when set, underline height scales slightly
/// so thicker slots read visually thicker. Null keeps the fixed 3px bar.
final double? widthHint;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final iconColor =
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant;
final barHeight = widthHint == null
? 3.0
: (2.0 + (widthHint! / kThicknessLarge).clamp(0.0, 1.0) * 3.0);
return Tooltip(
message: tooltip,
child: InkWell(
@@ -131,7 +140,7 @@ class PenSlotButton extends StatelessWidget {
const SizedBox(height: 3),
Container(
width: 16,
height: 3,
height: barHeight,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
@@ -145,6 +154,119 @@ class PenSlotButton extends StatelessWidget {
}
}
/// Compact thickness control: S / M / L presets + a custom slider. Writes the
/// chosen page-width fraction via [onChanged] (typically
/// [PenSlotsController.setActiveWidth]).
class ThicknessPickerButton extends StatelessWidget {
const ThicknessPickerButton({
super.key,
required this.width,
required this.onChanged,
this.tooltip = 'Thickness',
});
/// Current stroke width (page-width fraction).
final double width;
final ValueChanged<double> onChanged;
final String tooltip;
static String _labelFor(double w) {
if ((w - kThicknessSmall).abs() < 0.0003) return 'S';
if ((w - kThicknessMedium).abs() < 0.0003) return 'M';
if ((w - kThicknessLarge).abs() < 0.0003) return 'L';
return '·';
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return PopupMenuButton<double>(
tooltip: tooltip,
onSelected: onChanged,
itemBuilder: (context) => [
PopupMenuItem<double>(
value: kThicknessSmall,
child: Row(
children: [
const Text('S'),
const Spacer(),
if ((width - kThicknessSmall).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
value: kThicknessMedium,
child: Row(
children: [
const Text('M'),
const Spacer(),
if ((width - kThicknessMedium).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
value: kThicknessLarge,
child: Row(
children: [
const Text('L'),
const Spacer(),
if ((width - kThicknessLarge).abs() < 0.0003)
Icon(Icons.check, size: 18, color: cs.primary),
],
),
),
PopupMenuItem<double>(
enabled: false,
child: SizedBox(
width: 180,
child: StatefulBuilder(
builder: (context, setLocal) {
final v = width.clamp(kPenSlotWidthMin, kPenSlotWidthMax);
return Slider(
value: v,
min: kPenSlotWidthMin,
max: kPenSlotWidthMax,
onChanged: (next) {
onChanged(next);
setLocal(() {});
},
);
},
),
),
),
],
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.line_weight, size: 20, color: cs.onSurfaceVariant),
const SizedBox(width: 2),
Text(
_labelFor(width),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: cs.onSurfaceVariant,
),
),
],
),
),
);
}
}
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
///
/// Highlighter and eraser remain separate tools. Tapping the button opens a

View File

@@ -19,6 +19,7 @@ import '../engine/brush.dart';
import '../engine/shape_geometry.dart';
import '../input/pen_config.dart';
import '../input/pen_input_service.dart';
import '../input/pen_slots.dart';
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
import '../layout/viewport_fit.dart';
import '../pdf/slide_export.dart';
@@ -58,29 +59,21 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
/// The single active-tool state (shared model across the 3 editors).
EditorToolKind _tool = EditorToolKind.brush;
/// Selected brush for the BRUSH tool. Highlighter tool uses the highlighter
/// brush; local state only (not persisted — TODO(brush-persist-selection)).
BrushKind _penBrush = BrushKind.fountainPen;
/// Selected shape for the SHAPE tool.
ShapeKind _shapeKind = ShapeKind.line;
/// Index of the currently selected committed stroke (SELECT tool), or null.
int? _selectedStroke;
/// rnote-style per-brush color memory (see PenNoteScreen). In-memory only.
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;
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
? BrushKind.highlighter
: _penBrush;
BrushKind get _penBrush =>
_penSlots?.active.brush ?? BrushKind.fountainPen;
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
Color get _color => _tool == EditorToolKind.highlighter
? _highlighterColor
: (_penSlots?.active.color ?? Colors.black);
bool _allowFingerDrawing = false;
bool _needsCenter = true;
@@ -88,9 +81,9 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
double? _scrub;
PenConfigController? _penConfig;
PenSlotsController? _penSlots;
final TransformationController _transform = TransformationController();
static const double _penWidthFraction = 0.006;
static const double _highlighterWidthFraction = 0.02;
static const Size _fallbackSlide = Size(1600, 900);
@@ -125,15 +118,23 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
}
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;
});
}
@@ -141,10 +142,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
if (mounted) setState(() {});
}
void _onPenSlotsChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_penConfig?.removeListener(_onPenConfigChanged);
_penConfig?.dispose();
_penSlots?.removeListener(_onPenSlotsChanged);
_penSlots?.dispose();
_transform.dispose();
super.dispose();
}
@@ -300,7 +307,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
double get _strokeWidth => _tool == EditorToolKind.highlighter
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
: (_penConfig?.value.penWidth ?? _penWidthFraction);
: (_penSlots?.active.width ?? 0.006);
// ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ──
@@ -465,17 +472,19 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
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,
selected: _tool == EditorToolKind.brush && _penBrush == b,
color: _brushColors[b] ?? Colors.black,
tooltip: brushLabelEn(b),
onPressed: () => setState(() {
_penBrush = b;
_tool = EditorToolKind.brush;
}),
kind: slot.brush,
selected: _tool == EditorToolKind.brush &&
(_penSlots?.activeId ?? 'slot_0') == slot.id,
color: slot.color,
widthHint: slot.width,
tooltip: brushLabelEn(slot.brush),
onPressed: () {
_penSlots?.select(slot.id);
setState(() => _tool = EditorToolKind.brush);
},
),
ToolButton(
icon: Icons.brush_outlined,
@@ -530,6 +539,10 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
),
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,
@@ -558,13 +571,17 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
_tool != EditorToolKind.eraser &&
_tool != EditorToolKind.select;
return GestureDetector(
onTap: () => setState(() {
onTap: () {
if (_tool == EditorToolKind.eraser ||
_tool == EditorToolKind.select) {
_tool = EditorToolKind.brush;
setState(() => _tool = EditorToolKind.brush);
}
_brushColors[_activeColorBrush] = c;
}),
if (_tool == EditorToolKind.highlighter) {
setState(() => _highlighterColor = c);
} else {
_penSlots?.setActiveColor(c);
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),

View File

@@ -1,11 +1,10 @@
// lib/editor/canvas/sticky_note_overlay.dart
//
// Paper-sticky UX for PDF scratch links: a floating card on the viewer that
// inks into the SAME SidecarRepository the PDF editor holds. Shares the parent
// editor's brush/color/tool so there is one toolbar mental model (OneNote-like).
// Page-anchored paper sticky: sized/positioned by the parent in page space,
// shares the editor brush/color/tool, locks inner pan/zoom so writing feels
// like drawing on the sticky surface itself.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
@@ -24,7 +23,7 @@ import 'pen_stroke.dart';
/// Default world size for a fresh sticky scratchpad (absolute px).
const Size kStickyWorldSize = Size(1200, 900);
/// Floating sticky-note card: write → autosave into [repo] under [link.id].
/// Floating sticky-note card glued to a PDF page (parent supplies pixel size).
class StickyNoteOverlay extends StatefulWidget {
const StickyNoteOverlay({
super.key,
@@ -32,9 +31,12 @@ class StickyNoteOverlay extends StatefulWidget {
required this.repo,
required this.onClose,
required this.onDelete,
required this.onDragPx,
required this.onResizePx,
this.brush = BrushKind.ballpoint,
this.color = const Color(0xFF1A1A1A),
this.tool = EditorToolKind.brush,
this.strokeWidth = 0.008,
this.allowFingerDrawing = false,
});
@@ -43,10 +45,16 @@ class StickyNoteOverlay extends StatefulWidget {
final VoidCallback onClose;
final VoidCallback onDelete;
/// Shared from the parent PDF toolbar (no mini duplicate palette).
/// Header drag delta in viewer/page pixels.
final void Function(double dx, double dy) onDragPx;
/// Corner resize delta in viewer/page pixels.
final void Function(double dx, double dy) onResizePx;
final BrushKind brush;
final Color color;
final EditorToolKind tool;
final double strokeWidth;
final bool allowFingerDrawing;
@override
@@ -62,10 +70,6 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
Timer? _saveTimer;
bool _dirty = false;
/// On-screen card size (user-resizable). World canvas stays [_world].
double _cardW = 300;
double _cardH = 360;
@override
void initState() {
super.initState();
@@ -73,10 +77,6 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
if (pad != null) {
_world = Size(pad.canvasWidth, pad.canvasHeight);
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
// Prefer a card that roughly matches aspect of the world, clamped.
final aspect = _world.width / math.max(_world.height, 1);
_cardW = (280.0 * aspect).clamp(220.0, 520.0);
_cardH = (_cardW / aspect + 40).clamp(260.0, 640.0);
}
}
@@ -84,15 +84,7 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
void dispose() {
_saveTimer?.cancel();
if (_dirty) {
widget.repo.scheduleScratchpadSave(
widget.link.id,
SidecarScratchpad(
canvasWidth: _world.width,
canvasHeight: _world.height,
strokes: List<InkStroke>.of(_strokes),
),
);
widget.repo.flush();
_persist(flush: true);
}
_transform.dispose();
super.dispose();
@@ -101,11 +93,11 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
void _scheduleSave() {
_dirty = true;
_saveTimer?.cancel();
_saveTimer = Timer(const Duration(milliseconds: 600), _saveNow);
_saveTimer = Timer(const Duration(milliseconds: 600), () => _persist());
}
Future<void> _saveNow() async {
if (!_dirty) return;
Future<void> _persist({bool flush = false}) async {
if (!_dirty && !flush) return;
widget.repo.scheduleScratchpadSave(
widget.link.id,
SidecarScratchpad(
@@ -115,7 +107,7 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
),
);
_dirty = false;
await widget.repo.flush();
if (flush) await widget.repo.flush();
}
void _onStrokeComplete(PenStroke pen) {
@@ -145,7 +137,7 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
Future<void> _close() async {
_saveTimer?.cancel();
await _saveNow();
await _persist(flush: true);
widget.onClose();
}
@@ -156,6 +148,7 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
case EditorToolKind.select:
return CanvasTool.select;
case EditorToolKind.highlighter:
return CanvasTool.highlighter;
case EditorToolKind.brush:
case EditorToolKind.shape:
case EditorToolKind.text:
@@ -168,13 +161,6 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
? BrushKind.highlighter
: widget.brush;
void _onResizeDrag(DragUpdateDetails d) {
setState(() {
_cardW = (_cardW + d.delta.dx).clamp(220.0, 640.0);
_cardH = (_cardH + d.delta.dy).clamp(240.0, 720.0);
});
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -182,99 +168,121 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
elevation: 8,
borderRadius: BorderRadius.circular(4),
color: const Color(0xFFFFF8E1),
child: SizedBox(
width: _cardW,
height: _cardH,
child: Stack(
children: [
Column(
children: [
Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: const BoxDecoration(
color: Color(0xFFFFE082),
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
),
child: Row(
children: [
const Icon(Icons.sticky_note_2, size: 18),
const SizedBox(width: 6),
const Expanded(
child: Text(
'便利贴',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
Text(
'用顶栏笔/色',
style: TextStyle(
fontSize: 11,
color: cs.onSurface.withValues(alpha: 0.55),
),
),
IconButton(
tooltip: '删除',
icon: const Icon(Icons.delete_outline, size: 18),
visualDensity: VisualDensity.compact,
onPressed: () async {
await _saveNow();
widget.onDelete();
},
),
IconButton(
tooltip: '收起',
icon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
onPressed: _close,
),
],
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Column(
children: [
_StickyHeader(
onDragDelta: widget.onDragPx,
onClose: _close,
onDelete: () async {
await _persist(flush: true);
widget.onDelete();
},
cs: cs,
),
Expanded(
child: PenCanvas(
pageSize: _world,
strokes: penStrokesFromInk(_strokes, _world),
transformationController: _transform,
tool: _canvasTool,
brush: _canvasBrush,
color: widget.color,
strokeWidth: widget.strokeWidth,
eraserRadius: kDefaultEraserRadius,
allowFingerDrawing: widget.allowFingerDrawing,
scaleEnabled: false,
panEnabled: false,
minScale: 1.0,
maxScale: 1.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
),
Expanded(
child: ClipRect(
child: PenCanvas(
pageSize: _world,
strokes: penStrokesFromInk(_strokes, _world),
transformationController: _transform,
tool: _canvasTool,
brush: _canvasBrush,
color: widget.color,
strokeWidth: brushProfileFor(_canvasBrush).baseWidthFraction,
eraserRadius: kDefaultEraserRadius,
allowFingerDrawing: widget.allowFingerDrawing,
minScale: 0.2,
maxScale: 4.0,
onStrokeComplete: _onStrokeComplete,
onEraseStroke: _onErase,
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
),
),
),
],
),
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
onPanUpdate: _onResizeDrag,
child: MouseRegion(
cursor: SystemMouseCursors.resizeUpLeftDownRight,
child: SizedBox(
width: 28,
height: 28,
child: Icon(
Icons.south_east,
size: 16,
color: cs.onSurface.withValues(alpha: 0.45),
),
),
],
),
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
onPanUpdate: (d) => widget.onResizePx(d.delta.dx, d.delta.dy),
child: MouseRegion(
cursor: SystemMouseCursors.resizeUpLeftDownRight,
child: SizedBox(
width: 28,
height: 28,
child: Icon(
Icons.south_east,
size: 16,
color: cs.onSurface.withValues(alpha: 0.45),
),
),
),
),
),
],
),
);
}
}
class _StickyHeader extends StatelessWidget {
const _StickyHeader({
required this.onDragDelta,
required this.onClose,
required this.onDelete,
required this.cs,
});
final void Function(double dx, double dy) onDragDelta;
final VoidCallback onClose;
final VoidCallback onDelete;
final ColorScheme cs;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanUpdate: (d) => onDragDelta(d.delta.dx, d.delta.dy),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: const BoxDecoration(
color: Color(0xFFFFE082),
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
),
child: Row(
children: [
const Icon(Icons.drag_indicator, size: 18),
const SizedBox(width: 4),
const Expanded(
child: Text(
'便利贴',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
Text(
'拖标题定位 · 角缩放',
style: TextStyle(
fontSize: 10,
color: cs.onSurface.withValues(alpha: 0.5),
),
),
IconButton(
tooltip: '删除',
icon: const Icon(Icons.delete_outline, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onDelete,
),
IconButton(
tooltip: '收起',
icon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
onPressed: onClose,
),
],
),
),

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();
}
}

View File

@@ -128,12 +128,38 @@ class SidecarRepository {
}
final file = File('$sourceFilePath$kSidecarSuffix');
final loaded = await SidecarStore.read(file);
final sidecar = loaded ??
var sidecar = loaded ??
BadnoteSidecar(
sourceFile: _basename(sourceFilePath),
docType: docType,
pageCount: docType == 'notebook' ? 1 : null,
createdAt: DateTime.now().toUtc(),
);
// Standalone notebooks always carry an explicit pageCount (min 1). Older
// sidecars that omit it are normalized in-memory on open.
if (docType == 'notebook' &&
(sidecar.pageCount == null || sidecar.pageCount! < 1)) {
sidecar = BadnoteSidecar(
version: sidecar.version,
sourceFile: sidecar.sourceFile,
docType: sidecar.docType,
title: sidecar.title,
pageCount: 1,
rotation: sidecar.rotation,
createdAt: sidecar.createdAt,
updatedAt: sidecar.updatedAt,
strokes: sidecar.strokes,
highlights: sidecar.highlights,
texts: sidecar.texts,
bookmarks: sidecar.bookmarks,
scratchLinks: sidecar.scratchLinks,
legacyAnnotations: sidecar.legacyAnnotations,
ocrText: sidecar.ocrText,
pageText: sidecar.pageText,
legacyId: sidecar.legacyId,
background: sidecar.background,
);
}
final repo = SidecarRepository._(
sourceFilePath: sourceFilePath,
sidecar: sidecar,
@@ -220,6 +246,14 @@ class SidecarRepository {
_replace(strokes: next);
}
/// Replace the standalone-notebook page count and schedule a save. No-op if
/// unchanged. [count] is clamped to at least 1.
void schedulePageCountSave(int count) {
final next = count < 1 ? 1 : count;
if (_sidecar.pageCount == next) return;
_replace(pageCount: next);
}
/// Replace the highlight rects for [pageIndex] and schedule a save.
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
final next = Map<int, List<SidecarHighlight>>.from(_sidecar.highlights);
@@ -345,6 +379,7 @@ class SidecarRepository {
/// synchronously here so a later edit can't corrupt an in-flight write.
void _replace({
String? title,
int? pageCount,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
Map<int, List<SidecarText>>? texts,
@@ -362,7 +397,7 @@ class SidecarRepository {
sourceFile: _sidecar.sourceFile,
docType: _sidecar.docType,
title: title ?? _sidecar.title,
pageCount: _sidecar.pageCount,
pageCount: pageCount ?? _sidecar.pageCount,
rotation: _sidecar.rotation,
createdAt: _sidecar.createdAt,
updatedAt: DateTime.now().toUtc(),

View File

@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'editor/pdf/pen_capture_region.dart';
import 'editor/persistence/sidecar_flush_observer.dart';
import 'theme/app_theme.dart';
import 'diagnostics/badnote_log.dart';
@@ -18,7 +19,9 @@ import 'services/webdav_sync_service.dart';
import 'storage/sqlite_to_sidecar_migrator.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Kind-gated PDF pen capture MUST install before runApp — without it
// PenCaptureRegion.currentPointerKind stays null and stylus ink never hits.
PenCaptureBinding.ensureInitialized();
// pdfrx native engine init (required before any PdfViewer is built).
pdfrxFlutterInitialize();

View File

@@ -2,11 +2,8 @@
//
// A PDF-anchored scratch link: a sticky-note "tab" placed at a normalized
// position (nx, ny in [0,1]) on a specific page of a document. Tapping the
// anchor opens a split view whose right pane is an infinite freehand scratchpad
// that BELONGS TO THIS ANCHOR (keyed by [id]).
//
// Plain immutable value class (no freezed codegen) so it compiles without a
// build_runner step. Equality is by value so anchors can be diffed in lists.
// anchor opens an on-page sticky card that BELONGS TO THIS ANCHOR (keyed by
// [id]). Optional [nw]/[nh] size the expanded card as fractions of the page.
import 'package:flutter/foundation.dart';
@@ -18,6 +15,8 @@ class ScratchLink {
required this.pageIndex,
required this.nx,
required this.ny,
this.nw = 0.42,
this.nh = 0.36,
});
/// Stable anchor id (uuid). Doubles as the scratchpad storage key so each
@@ -30,18 +29,26 @@ class ScratchLink {
/// 0-based page the anchor sits on.
final int pageIndex;
/// Normalized horizontal position on the page, in [0, 1].
/// Normalized horizontal position on the page, in [0, 1] (top-left of card).
final double nx;
/// Normalized vertical position on the page, in [0, 1].
/// Normalized vertical position on the page, in [0, 1] (top-left of card).
final double ny;
/// Expanded card width as a fraction of page width (clamped on write).
final double nw;
/// Expanded card height as a fraction of page height.
final double nh;
ScratchLink copyWith({
String? id,
String? documentId,
int? pageIndex,
double? nx,
double? ny,
double? nw,
double? nh,
}) =>
ScratchLink(
id: id ?? this.id,
@@ -49,6 +56,8 @@ class ScratchLink {
pageIndex: pageIndex ?? this.pageIndex,
nx: nx ?? this.nx,
ny: ny ?? this.ny,
nw: nw ?? this.nw,
nh: nh ?? this.nh,
);
Map<String, dynamic> toJson() => {
@@ -57,6 +66,8 @@ class ScratchLink {
'pageIndex': pageIndex,
'nx': nx,
'ny': ny,
'nw': nw,
'nh': nh,
};
factory ScratchLink.fromJson(Map<String, dynamic> json) => ScratchLink(
@@ -65,6 +76,8 @@ class ScratchLink {
pageIndex: (json['pageIndex'] as num).toInt(),
nx: (json['nx'] as num).toDouble(),
ny: (json['ny'] as num).toDouble(),
nw: (json['nw'] as num?)?.toDouble() ?? 0.42,
nh: (json['nh'] as num?)?.toDouble() ?? 0.36,
);
@override
@@ -76,13 +89,15 @@ class ScratchLink {
documentId == other.documentId &&
pageIndex == other.pageIndex &&
nx == other.nx &&
ny == other.ny;
ny == other.ny &&
nw == other.nw &&
nh == other.nh;
@override
int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny);
int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny, nw, nh);
@override
String toString() =>
'ScratchLink(id: $id, documentId: $documentId, pageIndex: $pageIndex, '
'nx: $nx, ny: $ny)';
'nx: $nx, ny: $ny, nw: $nw, nh: $nh)';
}

View File

@@ -265,6 +265,7 @@ class VaultService {
BadnoteSidecar(
docType: 'notebook',
title: pageTitle,
pageCount: 1,
createdAt: now,
updatedAt: now,
),
@@ -311,6 +312,7 @@ class VaultService {
BadnoteSidecar(
docType: 'notebook',
title: title,
pageCount: 1,
createdAt: now,
updatedAt: now,
),
@@ -414,6 +416,7 @@ class VaultService {
final sidecar = BadnoteSidecar(
docType: 'notebook',
title: trimmed.isEmpty ? null : trimmed,
pageCount: 1,
createdAt: now,
updatedAt: now,
);

View File

@@ -1,11 +1,11 @@
// test/pen_brush_color_memory_test.dart
//
// Pins rnote-style per-brush color memory in the note editor: each brush
// remembers its OWN color, selecting a brush restores that brush's color (the
// PenCanvas receives it), and picking a color updates ONLY the active brush's
// entry — switching back to a different brush restores the other color.
// Pins OneNote-style independent pen slots in the note editor: each slot
// remembers its OWN brush + color + width; selecting a slot restores that
// slot's color (the PenCanvas receives it), and picking a color updates ONLY
// the active slot — switching back to a different slot restores the other color.
//
// Drives the real PenNoteScreen toolbar (BrushPickerButton popup + color dots)
// Drives the real PenNoteScreen toolbar (PenSlotButton + color dots)
// and reads PenCanvas.color to assert the active drawing color.
import 'package:flutter/material.dart';
@@ -15,6 +15,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:badnote/editor/canvas/pen_canvas.dart';
import 'package:badnote/editor/canvas/pen_note_screen.dart';
import 'package:badnote/editor/canvas/pen_palette_widgets.dart';
import 'package:badnote/editor/engine/brush.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -22,18 +24,17 @@ void main() {
Color canvasColor(WidgetTester tester) =>
tester.widget<PenCanvas>(find.byType(PenCanvas)).color;
/// Pick the brush named [label] from the BrushPickerButton popup menu.
Future<void> selectBrush(WidgetTester tester, String label) async {
// The brush picker carries the 'Brush' tooltip.
await tester.tap(find.byTooltip('Brush'));
await tester.pumpAndSettle();
await tester.tap(find.text(label).last);
/// Activate the [PenSlotButton] for [kind] via its onPressed (avoids hit-test
/// collisions with the floating back button over the left of the palette).
Future<void> selectSlot(WidgetTester tester, BrushKind kind) async {
final finder = find.byWidgetPredicate(
(w) => w is PenSlotButton && w.kind == kind,
);
tester.widget<PenSlotButton>(finder).onPressed();
await tester.pumpAndSettle();
}
/// Tap the toolbar color dot whose swatch is exactly [c]. The dot is an
/// AnimatedContainer (the swatch) inside a GestureDetector; tap the gesture
/// detector ancestor so the onTap fires.
/// Tap the toolbar color dot whose swatch is exactly [c].
Future<void> tapColorDot(WidgetTester tester, Color c) async {
final swatch = find.byWidgetPredicate((w) =>
w is AnimatedContainer &&
@@ -41,40 +42,49 @@ void main() {
(w.decoration as BoxDecoration).color == c &&
(w.decoration as BoxDecoration).shape == BoxShape.circle);
final gd = find.ancestor(of: swatch, matching: find.byType(GestureDetector));
await tester.tap(gd.first);
await tester.ensureVisible(gd.first);
await tester.pumpAndSettle();
await tester.tap(gd.first, warnIfMissed: false);
await tester.pump();
}
testWidgets('each brush remembers its own color; switching restores it',
testWidgets('each pen slot remembers its own color; switching restores it',
(tester) async {
SharedPreferences.setMockInitialValues({});
// Wide surface so the palette + color dots aren't crushed under chrome.
await tester.binding.setSurfaceSize(const Size(1280, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const ProviderScope(
child: MaterialApp(home: PenNoteScreen()),
));
await tester.pump(); // let PenConfig load
await tester.pump(); // let PenConfig + PenSlots load
await tester.pump();
// Defaults from _brushColors: fountain pen = black, ballpoint = blue.
// Defaults: fountain pen = black, ballpoint = blue.
expect(canvasColor(tester), Colors.black,
reason: 'fountain pen starts black');
// Switch to the ballpoint brush → its remembered color (blue) becomes active.
await selectBrush(tester, 'Ballpoint');
// Switch to the ballpoint slot → its remembered color (blue) becomes active.
await selectSlot(tester, BrushKind.ballpoint);
expect(canvasColor(tester), Colors.blue,
reason: 'selecting ballpoint restores ITS remembered color');
// Change the ACTIVE (ballpoint) brush's color to red via a color dot.
await tapColorDot(tester, Colors.red);
expect(canvasColor(tester), Colors.red,
reason: 'color change applies to the active brush');
// Change the ACTIVE (ballpoint) slot's color via a palette color dot.
const paletteRed = Color(0xFFC62828);
await tapColorDot(tester, paletteRed);
await tester.pump();
expect(canvasColor(tester), paletteRed,
reason: 'color change applies to the active slot');
// Switch back to the fountain pen → its color is still black (unchanged).
await selectBrush(tester, 'Fountain pen');
await selectSlot(tester, BrushKind.fountainPen);
expect(canvasColor(tester), Colors.black,
reason: 'fountain pen color was NOT affected by changing ballpoint');
// Back to ballpoint → it remembers the red we set.
await selectBrush(tester, 'Ballpoint');
expect(canvasColor(tester), Colors.red,
await selectSlot(tester, BrushKind.ballpoint);
expect(canvasColor(tester), paletteRed,
reason: 'ballpoint remembers its own updated color');
});
}

75
test/pen_slots_test.dart Normal file
View File

@@ -0,0 +1,75 @@
// Unit tests for PenSlot / PenSlotsController persistence and independence.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:badnote/editor/engine/brush.dart';
import 'package:badnote/editor/input/pen_slots.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('PenSlot toJson / fromJson', () {
test('round-trips defaults', () {
for (final slot in kDefaultPenSlots()) {
final restored = PenSlot.fromJson(slot.toJson());
expect(restored, slot);
}
});
});
group('PenSlotsController', () {
test('seeds three default pens on first load', () async {
final c = await PenSlotsController.load();
expect(c.slots.length, 3);
expect(c.active.brush, BrushKind.fountainPen);
expect(c.active.color, Colors.black);
expect(c.active.width, 0.006);
expect(c.slots[1].brush, BrushKind.ballpoint);
expect(c.slots[1].width, 0.0022);
expect(c.slots[2].brush, BrushKind.pencil);
expect(c.slots[2].width, 0.003);
c.dispose();
});
test('select restores brush + color + width as a unit', () async {
final c = await PenSlotsController.load();
await c.select('slot_1');
expect(c.active.brush, BrushKind.ballpoint);
expect(c.active.color, Colors.blue);
expect(c.active.width, 0.0022);
await c.setActiveColor(Colors.red);
await c.setActiveWidth(0.01);
await c.select('slot_0');
expect(c.active.color, Colors.black);
expect(c.active.width, 0.006);
await c.select('slot_1');
expect(c.active.color, Colors.red);
expect(c.active.width, 0.01);
c.dispose();
});
test('persists across load()', () async {
final first = await PenSlotsController.load();
await first.select('slot_2');
await first.setActiveColor(Colors.purple);
await first.setActiveWidth(0.008);
first.dispose();
final second = await PenSlotsController.load();
expect(second.activeId, 'slot_2');
expect(second.active.brush, BrushKind.pencil);
expect(second.active.color.toARGB32(), Colors.purple.toARGB32());
expect(second.active.width, 0.008);
second.dispose();
});
});
}

View File

@@ -122,6 +122,26 @@ void main() {
reopened.dispose();
});
test('schedulePageCountSave persists and restores pageCount', () async {
final repo = await SidecarRepository.open(src, debounce: _fast);
repo.schedulePageCountSave(5);
await repo.flush();
expect(repo.sidecar.pageCount, 5);
repo.dispose();
final reopened = await SidecarRepository.open(src, debounce: _fast);
expect(reopened.sidecar.pageCount, 5);
reopened.dispose();
});
test('notebook open normalizes missing pageCount to 1', () async {
final noteSrc = '${tmpDir.path}/notebook';
final repo =
await SidecarRepository.open(noteSrc, docType: 'notebook', debounce: _fast);
expect(repo.sidecar.pageCount, 1);
repo.dispose();
});
test('removing a highlight persists (un-highlight)', () async {
final repo = await SidecarRepository.open(src, debounce: _fast);
repo.scheduleHighlightSave(0, const [

View File

@@ -237,6 +237,7 @@ void main() {
expect(loaded, isNotNull);
expect(loaded!.title, 'My Algebra Notes');
expect(loaded.docType, 'notebook');
expect(loaded.pageCount, 1);
// The folder holds only the sidecar (and possibly its .bak/.tmp), never
// an importable source file.
final files = Directory(expectedFolder)