fix: restore PDF pen capture and overhaul sticky/pens/pages
All checks were successful
CI / Windows build (push) Successful in 9m55s
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
213
lib/editor/input/pen_slots.dart
Normal file
213
lib/editor/input/pen_slots.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
75
test/pen_slots_test.dart
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user