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.allowFingerDrawing = false,
|
||||||
this.minScale = 0.5,
|
this.minScale = 0.5,
|
||||||
this.maxScale = 8.0,
|
this.maxScale = 8.0,
|
||||||
|
this.scaleEnabled = true,
|
||||||
|
this.panEnabled = true,
|
||||||
this.onPenDebug,
|
this.onPenDebug,
|
||||||
this.thinning = kDefaultPenThinning,
|
this.thinning = kDefaultPenThinning,
|
||||||
this.pressureGamma = kNaturalPressureGamma,
|
this.pressureGamma = kNaturalPressureGamma,
|
||||||
@@ -163,6 +165,13 @@ class PenCanvas extends StatefulWidget {
|
|||||||
final double minScale;
|
final double minScale;
|
||||||
final double maxScale;
|
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`.
|
/// perfect_freehand pressure→width response, from `PenConfig.pressureSensitivity`.
|
||||||
final double thinning;
|
final double thinning;
|
||||||
|
|
||||||
@@ -804,7 +813,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
|
// governs touch/mouse: suppress pan while a single-finger / mouse stroke is
|
||||||
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
|
// in progress (finger-drawing mode); a 2nd pointer cancels the stroke first
|
||||||
// so a pinch re-enables pan/zoom immediately.
|
// 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
|
// Mirror committed strokes into the revision-tracked store (only re-mirrors
|
||||||
// when the parent handed us a new list identity).
|
// when the parent handed us a new list identity).
|
||||||
@@ -823,7 +832,7 @@ class _PenCanvasState extends State<PenCanvas> {
|
|||||||
minScale: widget.minScale,
|
minScale: widget.minScale,
|
||||||
maxScale: widget.maxScale,
|
maxScale: widget.maxScale,
|
||||||
panEnabled: panEnabled,
|
panEnabled: panEnabled,
|
||||||
scaleEnabled: true,
|
scaleEnabled: widget.scaleEnabled,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: widget.pageSize.width,
|
width: widget.pageSize.width,
|
||||||
height: widget.pageSize.height,
|
height: widget.pageSize.height,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import '../engine/undo_stack.dart';
|
|||||||
import '../input/diagnostic_logger.dart';
|
import '../input/diagnostic_logger.dart';
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
|
import '../input/pen_slots.dart';
|
||||||
import '../input/pressure_curve.dart'
|
import '../input/pressure_curve.dart'
|
||||||
show PressureCurve, kNaturalPressureFloor;
|
show PressureCurve, kNaturalPressureFloor;
|
||||||
import '../pdf/pen_capture_region.dart';
|
import '../pdf/pen_capture_region.dart';
|
||||||
@@ -166,6 +167,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
/// Loaded asynchronously in initState; null until ready.
|
/// Loaded asynchronously in initState; null until ready.
|
||||||
PenConfigController? _penConfig;
|
PenConfigController? _penConfig;
|
||||||
|
|
||||||
|
/// Independent OneNote-style pen slots (brush + color + width per slot).
|
||||||
|
PenSlotsController? _penSlots;
|
||||||
|
|
||||||
// ── Persistence ────────────────────────────────────────────────────────────
|
// ── Persistence ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Per-file sidecar persistence. Identity is the source file PATH (the
|
/// 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;
|
static const double _kScaleGlitchHi = 1.18;
|
||||||
|
|
||||||
/// A single-frame focal-midpoint jump beyond this is a touch misread → drop.
|
/// 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
|
/// Matrix scale captured at the current baseline (gesture start or the last
|
||||||
/// pointer-count re-baseline). Null when no pinch is active.
|
/// 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.
|
// path (they disable pen capture), so they stay as their own booleans.
|
||||||
EditorToolKind _tool = EditorToolKind.brush;
|
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.
|
/// Selected shape for the SHAPE tool.
|
||||||
ShapeKind _shapeKind = ShapeKind.line;
|
ShapeKind _shapeKind = ShapeKind.line;
|
||||||
|
|
||||||
@@ -270,22 +269,12 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
Offset? _selectLast;
|
Offset? _selectLast;
|
||||||
bool _selectDragging = false;
|
bool _selectDragging = false;
|
||||||
|
|
||||||
/// rnote-style per-brush color memory: each brush (and the highlighter)
|
/// Highlighter keeps its own color (not a pen slot).
|
||||||
/// remembers its own color. Selecting a brush restores its color; picking a
|
Color _highlighterColor = Colors.orange;
|
||||||
/// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The brush whose color the color-dots edit (highlighter tool ⇒ highlighter,
|
/// Active pen brush from the selected slot (fallback until slots load).
|
||||||
/// else the selected pen brush).
|
BrushKind get _penBrush =>
|
||||||
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
|
_penSlots?.active.brush ?? BrushKind.fountainPen;
|
||||||
? BrushKind.highlighter
|
|
||||||
: _penBrush;
|
|
||||||
|
|
||||||
/// When true the "select text" tool button is latched on.
|
/// When true the "select text" tool button is latched on.
|
||||||
bool _selectTextTool = false;
|
bool _selectTextTool = false;
|
||||||
@@ -327,16 +316,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
/// The active drawing color = the active brush's remembered color.
|
/// The active drawing color: highlighter tool uses [_highlighterColor], else
|
||||||
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
|
/// the active pen slot's color.
|
||||||
|
Color get _color => _tool == EditorToolKind.highlighter
|
||||||
|
? _highlighterColor
|
||||||
|
: (_penSlots?.active.color ?? Colors.black);
|
||||||
bool _allowFingerDrawing = false;
|
bool _allowFingerDrawing = false;
|
||||||
|
|
||||||
/// Whether the viewer currently has a non-empty text selection (drives the
|
/// Whether the viewer currently has a non-empty text selection (drives the
|
||||||
/// "highlight selection" action's enabled state).
|
/// "highlight selection" action's enabled state).
|
||||||
bool _hasSelection = false;
|
bool _hasSelection = false;
|
||||||
|
|
||||||
/// Pen width as a fraction of page width (base; pressure thins it down).
|
/// Fallback highlighter width as a fraction of page width.
|
||||||
static const double _penWidthFraction = 0.006;
|
|
||||||
static const double _highlighterWidthFraction = 0.02;
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
|
||||||
static const List<Color> _palette = kInkPalette;
|
static const List<Color> _palette = kInkPalette;
|
||||||
@@ -348,8 +339,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
!_selectTextMode &&
|
!_selectTextMode &&
|
||||||
!_placeLinkMode &&
|
!_placeLinkMode &&
|
||||||
!_removeHighlightMode &&
|
!_removeHighlightMode &&
|
||||||
!_textMode &&
|
!_textMode;
|
||||||
_expandedSticky == null;
|
|
||||||
|
|
||||||
/// True when the eraser tool is active.
|
/// True when the eraser tool is active.
|
||||||
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
bool get _isEraser => _tool == EditorToolKind.eraser && !_selectTextMode;
|
||||||
@@ -373,15 +363,23 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initPenConfig() async {
|
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) {
|
if (!mounted) {
|
||||||
controller.dispose();
|
config.dispose();
|
||||||
|
slots.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controller.addListener(_onPenConfigChanged);
|
config.addListener(_onPenConfigChanged);
|
||||||
|
slots.addListener(_onPenSlotsChanged);
|
||||||
setState(() {
|
setState(() {
|
||||||
_penConfig = controller;
|
_penConfig = config;
|
||||||
_allowFingerDrawing = controller.value.fingerDrawing;
|
_penSlots = slots;
|
||||||
|
_allowFingerDrawing = config.value.fingerDrawing;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +388,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_syncBarrelSelectText();
|
_syncBarrelSelectText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onPenSlotsChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
void _onHwPenChanged() {
|
void _onHwPenChanged() {
|
||||||
_syncBarrelSelectText();
|
_syncBarrelSelectText();
|
||||||
_dispatchHwSideButton();
|
_dispatchHwSideButton();
|
||||||
@@ -513,6 +515,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_liveStrokeVN.dispose();
|
_liveStrokeVN.dispose();
|
||||||
_penConfig?.removeListener(_onPenConfigChanged);
|
_penConfig?.removeListener(_onPenConfigChanged);
|
||||||
_penConfig?.dispose();
|
_penConfig?.dispose();
|
||||||
|
_penSlots?.removeListener(_onPenSlotsChanged);
|
||||||
|
_penSlots?.dispose();
|
||||||
PenInputService.instance.removeListener(_onHwPenChanged);
|
PenInputService.instance.removeListener(_onHwPenChanged);
|
||||||
PenInputService.instance.stop();
|
PenInputService.instance.stop();
|
||||||
DiagnosticLogger.instance.stop();
|
DiagnosticLogger.instance.stop();
|
||||||
@@ -700,7 +704,11 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onFingerPointer(PointerEvent event) {
|
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.kind != PointerDeviceKind.touch) return;
|
||||||
|
|
||||||
if (event is PointerDownEvent) {
|
if (event is PointerDownEvent) {
|
||||||
@@ -995,7 +1003,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
|
|
||||||
double _currentStrokeWidth() => _tool == EditorToolKind.highlighter
|
double _currentStrokeWidth() => _tool == EditorToolKind.highlighter
|
||||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
: (_penSlots?.active.width ?? 0.006);
|
||||||
|
|
||||||
PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter
|
PenStrokeKind _currentKind() => _tool == EditorToolKind.highlighter
|
||||||
? PenStrokeKind.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() {
|
void _closeSticky() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final open = _expandedSticky;
|
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
|
// delete. Sized in screen px so the tap target stays usable at any
|
||||||
// zoom; positioned at (nx*pageW, ny*pageH).
|
// zoom; positioned at (nx*pageW, ny*pageH).
|
||||||
for (final link in linksOnPage)
|
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(
|
Positioned(
|
||||||
left: link.nx * pageW - _kMarkerSize / 2,
|
left: (_expandedSticky!.nx * pageW).clamp(0.0, pageW * 0.85),
|
||||||
top: link.ny * pageH - _kMarkerSize / 2,
|
top: (_expandedSticky!.ny * pageH).clamp(0.0, pageH * 0.85),
|
||||||
width: _kMarkerSize,
|
width: (_expandedSticky!.nw * pageW).clamp(120.0, pageW),
|
||||||
height: _kMarkerSize,
|
height: (_expandedSticky!.nh * pageH).clamp(140.0, pageH),
|
||||||
child: _ScratchLinkMarker(
|
child: StickyNoteOverlay(
|
||||||
onTap: () => _openScratchLink(link),
|
key: ValueKey(_expandedSticky!.id),
|
||||||
onLongPress: () => _confirmDeleteScratchLink(link),
|
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.
|
// outside the arena by PenCaptureRegion) is never stolen.
|
||||||
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
viewerOverlayBuilder: (context, size, handleLinkTap) {
|
||||||
return [
|
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(
|
Positioned.fill(
|
||||||
child: RawGestureDetector(
|
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,
|
behavior: HitTestBehavior.translucent,
|
||||||
gestures: {
|
gestures: {
|
||||||
_TwoFingerPinch:
|
_TwoFingerPinch:
|
||||||
@@ -2137,18 +2211,6 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
child: const SizedBox.expand(),
|
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(
|
Positioned.fill(
|
||||||
child: PenCaptureRegion(
|
child: PenCaptureRegion(
|
||||||
captureEnabled: _penCaptureEnabled,
|
captureEnabled: _penCaptureEnabled,
|
||||||
@@ -2166,26 +2228,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
onBookmark: _addBookmark,
|
onBookmark: _addBookmark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_expandedSticky != null && _repo != null)
|
// Sticky is rendered in pageOverlaysBuilder (page-anchored).
|
||||||
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);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -2205,20 +2248,25 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// OneNote-style: each pen is its own slot with remembered color.
|
// OneNote-style: each pen slot restores brush + color + thickness.
|
||||||
for (final b in kPenToolBrushes)
|
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
|
||||||
PenSlotButton(
|
PenSlotButton(
|
||||||
kind: b,
|
kind: slot.brush,
|
||||||
selected: _tool == EditorToolKind.brush &&
|
selected: _tool == EditorToolKind.brush &&
|
||||||
_penBrush == b &&
|
(_penSlots?.activeId ?? 'slot_0') == slot.id &&
|
||||||
_penCaptureEnabled,
|
_penCaptureEnabled,
|
||||||
color: _brushColors[b] ?? Colors.black,
|
color: slot.color,
|
||||||
tooltip: brushLabel(b, l),
|
widthHint: slot.width,
|
||||||
|
tooltip: brushLabel(slot.brush, l),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() => _penBrush = b);
|
_penSlots?.select(slot.id);
|
||||||
_setTool(EditorToolKind.brush);
|
_setTool(EditorToolKind.brush);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ThicknessPickerButton(
|
||||||
|
width: _penSlots?.active.width ?? 0.006,
|
||||||
|
onChanged: (w) => _penSlots?.setActiveWidth(w),
|
||||||
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
|
selected: _tool == EditorToolKind.highlighter && _penCaptureEnabled,
|
||||||
@@ -2323,6 +2371,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
),
|
),
|
||||||
PaletteDivider(cs: cs),
|
PaletteDivider(cs: cs),
|
||||||
for (final c in _palette) _colorDot(c, cs),
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
|
ThicknessPickerButton(
|
||||||
|
width: _penSlots?.active.width ?? 0.006,
|
||||||
|
onChanged: (w) => _penSlots?.setActiveWidth(w),
|
||||||
|
),
|
||||||
PaletteDivider(cs: cs),
|
PaletteDivider(cs: cs),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
||||||
@@ -2423,11 +2475,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _colorDot(Color c, ColorScheme cs) {
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
// Selected against the ACTIVE brush's remembered color; a tap updates only
|
// Selected against the ACTIVE slot (or highlighter) color; a tap updates
|
||||||
// that brush's entry (rnote per-brush color memory). Inert in select mode.
|
// only that entry. Inert highlight in select mode.
|
||||||
final selected = _color == c && !_isSelect;
|
final selected = _color.toARGB32() == c.toARGB32() && !_isSelect;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setState(() => _brushColors[_activeColorBrush] = c),
|
onTap: () {
|
||||||
|
if (_tool == EditorToolKind.highlighter) {
|
||||||
|
setState(() => _highlighterColor = c);
|
||||||
|
} else {
|
||||||
|
_penSlots?.setActiveColor(c);
|
||||||
|
}
|
||||||
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
width: 28,
|
width: 28,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import '../engine/stroke_model.dart';
|
|||||||
import '../persistence/sidecar_repository.dart';
|
import '../persistence/sidecar_repository.dart';
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
|
import '../input/pen_slots.dart';
|
||||||
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
import '../layout/viewport_fit.dart';
|
import '../layout/viewport_fit.dart';
|
||||||
import '../notebook/ink_stroke_adapter.dart';
|
import '../notebook/ink_stroke_adapter.dart';
|
||||||
@@ -42,47 +43,42 @@ class PenNoteScreen extends ConsumerStatefulWidget {
|
|||||||
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
/// Live strokes in normalized coords (the canvas source of truth). Persisted
|
/// Per-page live strokes in normalized coords (the canvas source of truth).
|
||||||
/// back to the note as InkStroke via the adapter on save.
|
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||||
List<PenStroke> _strokes = const [];
|
|
||||||
|
|
||||||
/// 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>> _undo = [];
|
||||||
final List<List<PenStroke>> _redo = [];
|
final List<List<PenStroke>> _redo = [];
|
||||||
|
|
||||||
|
bool _showPageScrubber = false;
|
||||||
|
double? _pageScrub;
|
||||||
|
|
||||||
/// The single active-tool state (shared model across the 3 editors).
|
/// The single active-tool state (shared model across the 3 editors).
|
||||||
EditorToolKind _tool = EditorToolKind.brush;
|
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.
|
/// Selected shape for the SHAPE tool.
|
||||||
ShapeKind _shapeKind = ShapeKind.line;
|
ShapeKind _shapeKind = ShapeKind.line;
|
||||||
|
|
||||||
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
||||||
int? _selectedStroke;
|
int? _selectedStroke;
|
||||||
|
|
||||||
/// rnote-style per-brush color memory: each brush (and the highlighter)
|
/// Highlighter keeps its own color (not a pen slot).
|
||||||
/// remembers its own color. Selecting a brush restores its color; picking a
|
Color _highlighterColor = Colors.orange;
|
||||||
/// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The brush whose color the color-dots edit: the highlighter when the
|
/// Active pen brush from the selected slot (fallback until slots load).
|
||||||
/// highlighter tool is active, otherwise the selected pen brush.
|
BrushKind get _penBrush =>
|
||||||
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
|
_penSlots?.active.brush ?? BrushKind.fountainPen;
|
||||||
? BrushKind.highlighter
|
|
||||||
: _penBrush;
|
|
||||||
|
|
||||||
/// The active drawing color (the active brush's remembered color).
|
/// Active drawing color: highlighter tool uses [_highlighterColor], else the
|
||||||
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
|
/// 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
|
/// The page-background template painted behind the ink (rnote-style). Default
|
||||||
/// blank; persisted per-notebook in the sidecar.
|
/// 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.
|
/// Persistence flows through this note's `notebook.badnote.json` sidecar.
|
||||||
String? _notePath;
|
String? _notePath;
|
||||||
|
|
||||||
/// Per-file sidecar persistence sink (strokes page 0 + title), debounced and
|
/// Per-file sidecar persistence sink (strokes + pageCount + title), debounced
|
||||||
/// atomic — replaces the old SQLite Note/noteListProvider write path here.
|
/// and atomic — replaces the old SQLite Note/noteListProvider write path here.
|
||||||
SidecarRepository? _repo;
|
SidecarRepository? _repo;
|
||||||
|
|
||||||
/// Page index a standalone note's strokes live under in the sidecar.
|
|
||||||
static const int _notePageIndex = 0;
|
|
||||||
|
|
||||||
final TextEditingController _titleController = TextEditingController();
|
final TextEditingController _titleController = TextEditingController();
|
||||||
|
|
||||||
PenConfigController? _penConfig;
|
PenConfigController? _penConfig;
|
||||||
|
PenSlotsController? _penSlots;
|
||||||
final TransformationController _transform = TransformationController();
|
final TransformationController _transform = TransformationController();
|
||||||
|
|
||||||
static const double _penWidthFraction = 0.006;
|
|
||||||
static const double _highlighterWidthFraction = 0.02;
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
|
|
||||||
static const List<Color> _palette = kInkPalette;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -123,7 +124,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
_titleController.text = note.title;
|
_titleController.text = note.title;
|
||||||
// Seed from the in-memory note's strokes (e.g. tests) until the sidecar
|
// Seed from the in-memory note's strokes (e.g. tests) until the sidecar
|
||||||
// load resolves and (if present) overrides with persisted strokes.
|
// load resolves and (if present) overrides with persisted strokes.
|
||||||
_strokes = penStrokesFromInk(note.strokes, kNoteLogicalPage);
|
_strokesByPage[0] = penStrokesFromInk(note.strokes, kNoteLogicalPage);
|
||||||
} else {
|
} else {
|
||||||
_titleController.text = 'Untitled';
|
_titleController.text = 'Untitled';
|
||||||
}
|
}
|
||||||
@@ -131,9 +132,8 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
if (_notePath != null) _initPersistence(_notePath!);
|
if (_notePath != null) _initPersistence(_notePath!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the note's `notebook.badnote.json` sidecar and, if it holds persisted
|
/// Open the note's `notebook.badnote.json` sidecar and hydrate every page of
|
||||||
/// strokes / a title, hydrate the canvas from them. Strokes load as page-0
|
/// strokes plus title / background / pageCount.
|
||||||
/// [EditorStroke]s converted to [PenStroke] (mirrors the PDF editor).
|
|
||||||
Future<void> _initPersistence(String notePath) async {
|
Future<void> _initPersistence(String notePath) async {
|
||||||
final repo = await SidecarRepository.open(notePath, docType: 'notebook');
|
final repo = await SidecarRepository.open(notePath, docType: 'notebook');
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
@@ -141,19 +141,47 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
final loaded = repo.loadedStrokes[_notePageIndex];
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (loaded != null && loaded.isNotEmpty) {
|
_hydrateFromRepo(repo);
|
||||||
_strokes = [for (final es in loaded) _penStrokeFromEditor(es)];
|
|
||||||
}
|
|
||||||
final title = repo.loadedTitle;
|
|
||||||
if (title != null && title.isNotEmpty) {
|
|
||||||
_titleController.text = title;
|
|
||||||
}
|
|
||||||
_background = noteBackgroundFromName(repo.loadedBackground);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// EditorStroke → live PenStroke (mirror of the PDF editor's loader). Brush
|
||||||
/// is persisted on the EditorStroke now, so carry it through; old sidecars
|
/// is persisted on the EditorStroke now, so carry it through; old sidecars
|
||||||
/// without the field decode to fountainPen (back-compat default).
|
/// without the field decode to fountainPen (back-compat default).
|
||||||
@@ -170,15 +198,23 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _initPenConfig() async {
|
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) {
|
if (!mounted) {
|
||||||
controller.dispose();
|
config.dispose();
|
||||||
|
slots.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controller.addListener(_onPenConfigChanged);
|
config.addListener(_onPenConfigChanged);
|
||||||
|
slots.addListener(_onPenSlotsChanged);
|
||||||
setState(() {
|
setState(() {
|
||||||
_penConfig = controller;
|
_penConfig = config;
|
||||||
_allowFingerDrawing = controller.value.fingerDrawing;
|
_penSlots = slots;
|
||||||
|
_allowFingerDrawing = config.value.fingerDrawing;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +222,10 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onPenSlotsChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Flush any pending sidecar write before tearing down (atomic write
|
// Flush any pending sidecar write before tearing down (atomic write
|
||||||
@@ -197,6 +237,8 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
}
|
}
|
||||||
_penConfig?.removeListener(_onPenConfigChanged);
|
_penConfig?.removeListener(_onPenConfigChanged);
|
||||||
_penConfig?.dispose();
|
_penConfig?.dispose();
|
||||||
|
_penSlots?.removeListener(_onPenSlotsChanged);
|
||||||
|
_penSlots?.dispose();
|
||||||
_titleController.dispose();
|
_titleController.dispose();
|
||||||
_transform.dispose();
|
_transform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -256,11 +298,60 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
|
|
||||||
// ── Persistence ──────────────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Persist the live pen strokes + title to the note's `notebook.badnote.json`
|
/// Schedule a stroke save for [pageIndex] (defaults to current) without
|
||||||
/// sidecar (strokes as page-0 [EditorStroke]s; title via the sidecar's title
|
/// flushing. Used when switching pages so ink isn't lost mid-edit.
|
||||||
/// field), debounced/atomic via [SidecarRepository]. Creates the notebook
|
void _schedulePageStrokeSave([int? pageIndex]) {
|
||||||
/// folder lazily on first save when the screen was opened without a path.
|
final repo = _repo;
|
||||||
/// Refreshes the home list and triggers local OCR for search indexing.
|
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 {
|
Future<void> _save() async {
|
||||||
if (!_dirty) return;
|
if (!_dirty) return;
|
||||||
final notifier = ref.read(noteListProvider.notifier);
|
final notifier = ref.read(noteListProvider.notifier);
|
||||||
@@ -284,12 +375,16 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
}
|
}
|
||||||
final repo = _repo!;
|
final repo = _repo!;
|
||||||
|
|
||||||
final editorStrokes = <EditorStroke>[
|
|
||||||
for (final s in _strokes) EditorStroke.fromPenStroke(s),
|
|
||||||
];
|
|
||||||
repo.scheduleTitleSave(title);
|
repo.scheduleTitleSave(title);
|
||||||
repo.scheduleBackgroundSave(_background.name);
|
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();
|
await repo.flush();
|
||||||
|
|
||||||
// Refresh the home list so the title/recency update is visible on return.
|
// 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;
|
if (!mounted) return;
|
||||||
setState(() => _dirty = false);
|
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>[
|
final inkStrokes = <InkStroke>[
|
||||||
for (final s in _strokes)
|
for (final page in _strokesByPage.values)
|
||||||
inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
|
for (final s in page)
|
||||||
|
inkStrokeFromPen(s, kNoteLogicalPage, id: _uuid.v4(), createdAt: now),
|
||||||
];
|
];
|
||||||
_runLocalOcr(Note(
|
_runLocalOcr(Note(
|
||||||
id: _notePath!,
|
id: _notePath!,
|
||||||
@@ -341,7 +438,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
|
|||||||
|
|
||||||
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
||||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
: (_penSlots?.active.width ?? 0.006);
|
||||||
|
|
||||||
// ── SELECT tool: select / move / delete (reuses the undo stacks) ─────────────
|
// ── 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(
|
SafeArea(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
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),
|
borderRadius: BorderRadius.circular(28),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
child: Row(
|
child: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
scrollDirection: Axis.horizontal,
|
||||||
children: [
|
child: Row(
|
||||||
// OneNote-style: each pen is its own slot with remembered color.
|
mainAxisSize: MainAxisSize.min,
|
||||||
for (final b in kPenToolBrushes)
|
children: [
|
||||||
PenSlotButton(
|
// OneNote-style: each pen slot restores brush + color + thickness.
|
||||||
kind: b,
|
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
|
||||||
selected: _tool == EditorToolKind.brush && _penBrush == b,
|
PenSlotButton(
|
||||||
color: _brushColors[b] ?? Colors.black,
|
kind: slot.brush,
|
||||||
tooltip: brushLabelEn(b),
|
selected: _tool == EditorToolKind.brush &&
|
||||||
onPressed: () => setState(() {
|
(_penSlots?.activeId ?? 'slot_0') == slot.id,
|
||||||
_penBrush = b;
|
color: slot.color,
|
||||||
_tool = EditorToolKind.brush;
|
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(
|
if (_tool == EditorToolKind.select && _selectedStroke != null)
|
||||||
icon: Icons.brush_outlined,
|
ToolButton(
|
||||||
selected: _tool == EditorToolKind.highlighter,
|
icon: Icons.delete_outline,
|
||||||
tooltip: 'Highlighter',
|
selected: false,
|
||||||
onPressed: () =>
|
tooltip: 'Delete selection',
|
||||||
setState(() => _tool = EditorToolKind.highlighter),
|
onPressed: _deleteSelected,
|
||||||
),
|
),
|
||||||
ToolButton(
|
PaletteDivider(cs: cs),
|
||||||
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)
|
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.undo,
|
||||||
selected: false,
|
selected: false,
|
||||||
tooltip: 'Delete selection',
|
tooltip: 'Undo',
|
||||||
onPressed: _deleteSelected,
|
onPressed: _undo.isNotEmpty ? _performUndo : null,
|
||||||
),
|
),
|
||||||
PaletteDivider(cs: cs),
|
ToolButton(
|
||||||
ToolButton(
|
icon: Icons.redo,
|
||||||
icon: Icons.undo,
|
selected: false,
|
||||||
selected: false,
|
tooltip: 'Redo',
|
||||||
tooltip: 'Undo',
|
onPressed: _redo.isNotEmpty ? _performRedo : null,
|
||||||
onPressed: _undo.isNotEmpty ? _performUndo : null,
|
),
|
||||||
),
|
PaletteDivider(cs: cs),
|
||||||
ToolButton(
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
icon: Icons.redo,
|
ThicknessPickerButton(
|
||||||
selected: false,
|
width: _penSlots?.active.width ?? 0.006,
|
||||||
tooltip: 'Redo',
|
onChanged: (w) => _penSlots?.setActiveWidth(w),
|
||||||
onPressed: _redo.isNotEmpty ? _performRedo : null,
|
),
|
||||||
),
|
PaletteDivider(cs: cs),
|
||||||
PaletteDivider(cs: cs),
|
// Page-background template picker (rnote-style: blank / dots / ruled
|
||||||
for (final c in _palette) _colorDot(c, cs),
|
// / grid / cornell). Persists per-notebook in the sidecar.
|
||||||
PaletteDivider(cs: cs),
|
PopupMenuButton<NoteBackground>(
|
||||||
// Page-background template picker (rnote-style: blank / dots / ruled
|
tooltip: 'Page background',
|
||||||
// / grid / cornell). Persists per-notebook in the sidecar.
|
initialValue: _background,
|
||||||
PopupMenuButton<NoteBackground>(
|
onSelected: (b) {
|
||||||
tooltip: 'Page background',
|
setState(() {
|
||||||
initialValue: _background,
|
_background = b;
|
||||||
onSelected: (b) {
|
_dirty = true;
|
||||||
setState(() {
|
});
|
||||||
_background = b;
|
},
|
||||||
_dirty = true;
|
itemBuilder: (context) => [
|
||||||
});
|
for (final b in NoteBackground.values)
|
||||||
},
|
PopupMenuItem<NoteBackground>(
|
||||||
itemBuilder: (context) => [
|
value: b,
|
||||||
for (final b in NoteBackground.values)
|
child: Row(
|
||||||
PopupMenuItem<NoteBackground>(
|
mainAxisSize: MainAxisSize.min,
|
||||||
value: b,
|
children: [
|
||||||
child: Row(
|
Icon(noteBackgroundIcon(b), size: 20),
|
||||||
mainAxisSize: MainAxisSize.min,
|
const SizedBox(width: 10),
|
||||||
children: [
|
Text(noteBackgroundLabel(b)),
|
||||||
Icon(noteBackgroundIcon(b), size: 20),
|
if (b == _background) ...[
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Text(noteBackgroundLabel(b)),
|
Icon(Icons.check, size: 18, color: cs.primary),
|
||||||
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),
|
||||||
PaletteDivider(cs: cs),
|
ToolButton(
|
||||||
ToolButton(
|
icon:
|
||||||
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
_allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
||||||
selected: _allowFingerDrawing,
|
selected: _allowFingerDrawing,
|
||||||
tooltip: _allowFingerDrawing
|
tooltip: _allowFingerDrawing
|
||||||
? 'Finger drawing ON'
|
? 'Finger drawing ON'
|
||||||
: 'Finger drawing OFF (pen only)',
|
: 'Finger drawing OFF (pen only)',
|
||||||
onPressed: _toggleFingerDrawing,
|
onPressed: _toggleFingerDrawing,
|
||||||
),
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.settings_outlined,
|
icon: Icons.settings_outlined,
|
||||||
selected: false,
|
selected: false,
|
||||||
tooltip: 'Pen settings (width, pressure, eraser…)',
|
tooltip: 'Pen settings (width, pressure, eraser…)',
|
||||||
onPressed: _penConfig != null
|
onPressed: _penConfig != null
|
||||||
? () => showPenSettingsSheet(context, _penConfig!)
|
? () => showPenSettingsSheet(context, _penConfig!)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _colorDot(Color c, ColorScheme cs) {
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
// Selected against the ACTIVE brush's remembered color. A color tap updates
|
// Selected against the ACTIVE slot (or highlighter) color. A color tap
|
||||||
// only that brush's entry (rnote per-brush color memory).
|
// updates only the active slot / highlighter — not other slots.
|
||||||
final selected = _color.toARGB32() == c.toARGB32() &&
|
final selected = _color.toARGB32() == c.toARGB32() &&
|
||||||
_tool != EditorToolKind.eraser &&
|
_tool != EditorToolKind.eraser &&
|
||||||
_tool != EditorToolKind.select;
|
_tool != EditorToolKind.select;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setState(() {
|
onTap: () {
|
||||||
if (_tool == EditorToolKind.eraser ||
|
if (_tool == EditorToolKind.eraser ||
|
||||||
_tool == EditorToolKind.select) {
|
_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(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
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 '../../l10n/app_localizations.dart';
|
||||||
import '../engine/brush.dart';
|
import '../engine/brush.dart';
|
||||||
|
import '../input/pen_slots.dart';
|
||||||
import 'editor_tool.dart';
|
import 'editor_tool.dart';
|
||||||
|
|
||||||
/// Shared ink color palette for PDF / note / slide / scratch editors.
|
/// 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,
|
ShapeKind.arrow => Icons.arrow_outward,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// OneNote-style pen slot: each brush is its own toolbar button with a color
|
/// OneNote-style pen slot: each slot is its own toolbar button with a color
|
||||||
/// underline (per-brush remembered color). Prefer this over [BrushPickerButton]
|
/// underline (slot-remembered color). Prefer this over [BrushPickerButton]
|
||||||
/// when the UX wants pens visible side-by-side.
|
/// when the UX wants pens visible side-by-side.
|
||||||
class PenSlotButton extends StatelessWidget {
|
class PenSlotButton extends StatelessWidget {
|
||||||
const PenSlotButton({
|
const PenSlotButton({
|
||||||
@@ -98,6 +99,7 @@ class PenSlotButton extends StatelessWidget {
|
|||||||
required this.color,
|
required this.color,
|
||||||
required this.tooltip,
|
required this.tooltip,
|
||||||
required this.onPressed,
|
required this.onPressed,
|
||||||
|
this.widthHint,
|
||||||
});
|
});
|
||||||
|
|
||||||
final BrushKind kind;
|
final BrushKind kind;
|
||||||
@@ -106,11 +108,18 @@ class PenSlotButton extends StatelessWidget {
|
|||||||
final String tooltip;
|
final String tooltip;
|
||||||
final VoidCallback onPressed;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final iconColor =
|
final iconColor =
|
||||||
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant;
|
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant;
|
||||||
|
final barHeight = widthHint == null
|
||||||
|
? 3.0
|
||||||
|
: (2.0 + (widthHint! / kThicknessLarge).clamp(0.0, 1.0) * 3.0);
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -131,7 +140,7 @@ class PenSlotButton extends StatelessWidget {
|
|||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Container(
|
Container(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 3,
|
height: barHeight,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
borderRadius: BorderRadius.circular(2),
|
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).
|
/// A dropdown that selects the active PEN brush (fountain / ballpoint / pencil).
|
||||||
///
|
///
|
||||||
/// Highlighter and eraser remain separate tools. Tapping the button opens a
|
/// 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 '../engine/shape_geometry.dart';
|
||||||
import '../input/pen_config.dart';
|
import '../input/pen_config.dart';
|
||||||
import '../input/pen_input_service.dart';
|
import '../input/pen_input_service.dart';
|
||||||
|
import '../input/pen_slots.dart';
|
||||||
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
import '../layout/viewport_fit.dart';
|
import '../layout/viewport_fit.dart';
|
||||||
import '../pdf/slide_export.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).
|
/// The single active-tool state (shared model across the 3 editors).
|
||||||
EditorToolKind _tool = EditorToolKind.brush;
|
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.
|
/// Selected shape for the SHAPE tool.
|
||||||
ShapeKind _shapeKind = ShapeKind.line;
|
ShapeKind _shapeKind = ShapeKind.line;
|
||||||
|
|
||||||
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
/// Index of the currently selected committed stroke (SELECT tool), or null.
|
||||||
int? _selectedStroke;
|
int? _selectedStroke;
|
||||||
|
|
||||||
/// rnote-style per-brush color memory (see PenNoteScreen). In-memory only.
|
/// Highlighter keeps its own color (not a pen slot).
|
||||||
final Map<BrushKind, Color> _brushColors = {
|
Color _highlighterColor = Colors.orange;
|
||||||
BrushKind.fountainPen: Colors.black,
|
|
||||||
BrushKind.ballpoint: Colors.blue,
|
|
||||||
BrushKind.pencil: Colors.green,
|
|
||||||
BrushKind.highlighter: Colors.orange,
|
|
||||||
};
|
|
||||||
|
|
||||||
BrushKind get _activeColorBrush => _tool == EditorToolKind.highlighter
|
BrushKind get _penBrush =>
|
||||||
? BrushKind.highlighter
|
_penSlots?.active.brush ?? BrushKind.fountainPen;
|
||||||
: _penBrush;
|
|
||||||
|
|
||||||
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
|
Color get _color => _tool == EditorToolKind.highlighter
|
||||||
|
? _highlighterColor
|
||||||
|
: (_penSlots?.active.color ?? Colors.black);
|
||||||
|
|
||||||
bool _allowFingerDrawing = false;
|
bool _allowFingerDrawing = false;
|
||||||
bool _needsCenter = true;
|
bool _needsCenter = true;
|
||||||
@@ -88,9 +81,9 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
double? _scrub;
|
double? _scrub;
|
||||||
|
|
||||||
PenConfigController? _penConfig;
|
PenConfigController? _penConfig;
|
||||||
|
PenSlotsController? _penSlots;
|
||||||
final TransformationController _transform = TransformationController();
|
final TransformationController _transform = TransformationController();
|
||||||
|
|
||||||
static const double _penWidthFraction = 0.006;
|
|
||||||
static const double _highlighterWidthFraction = 0.02;
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
static const Size _fallbackSlide = Size(1600, 900);
|
static const Size _fallbackSlide = Size(1600, 900);
|
||||||
|
|
||||||
@@ -125,15 +118,23 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initPenConfig() async {
|
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) {
|
if (!mounted) {
|
||||||
controller.dispose();
|
config.dispose();
|
||||||
|
slots.dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controller.addListener(_onPenConfigChanged);
|
config.addListener(_onPenConfigChanged);
|
||||||
|
slots.addListener(_onPenSlotsChanged);
|
||||||
setState(() {
|
setState(() {
|
||||||
_penConfig = controller;
|
_penConfig = config;
|
||||||
_allowFingerDrawing = controller.value.fingerDrawing;
|
_penSlots = slots;
|
||||||
|
_allowFingerDrawing = config.value.fingerDrawing;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,10 +142,16 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onPenSlotsChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_penConfig?.removeListener(_onPenConfigChanged);
|
_penConfig?.removeListener(_onPenConfigChanged);
|
||||||
_penConfig?.dispose();
|
_penConfig?.dispose();
|
||||||
|
_penSlots?.removeListener(_onPenSlotsChanged);
|
||||||
|
_penSlots?.dispose();
|
||||||
_transform.dispose();
|
_transform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -300,7 +307,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
|
|
||||||
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
double get _strokeWidth => _tool == EditorToolKind.highlighter
|
||||||
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
? (_penConfig?.value.highlighterWidth ?? _highlighterWidthFraction)
|
||||||
: (_penConfig?.value.penWidth ?? _penWidthFraction);
|
: (_penSlots?.active.width ?? 0.006);
|
||||||
|
|
||||||
// ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ──
|
// ── SELECT tool: select / move / delete (per-slide, reuses the undo stacks) ──
|
||||||
|
|
||||||
@@ -465,17 +472,19 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// OneNote-style: each pen is its own slot with remembered color.
|
// OneNote-style: each pen slot restores brush + color + thickness.
|
||||||
for (final b in kPenToolBrushes)
|
for (final slot in _penSlots?.slots ?? kDefaultPenSlots())
|
||||||
PenSlotButton(
|
PenSlotButton(
|
||||||
kind: b,
|
kind: slot.brush,
|
||||||
selected: _tool == EditorToolKind.brush && _penBrush == b,
|
selected: _tool == EditorToolKind.brush &&
|
||||||
color: _brushColors[b] ?? Colors.black,
|
(_penSlots?.activeId ?? 'slot_0') == slot.id,
|
||||||
tooltip: brushLabelEn(b),
|
color: slot.color,
|
||||||
onPressed: () => setState(() {
|
widthHint: slot.width,
|
||||||
_penBrush = b;
|
tooltip: brushLabelEn(slot.brush),
|
||||||
_tool = EditorToolKind.brush;
|
onPressed: () {
|
||||||
}),
|
_penSlots?.select(slot.id);
|
||||||
|
setState(() => _tool = EditorToolKind.brush);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.brush_outlined,
|
icon: Icons.brush_outlined,
|
||||||
@@ -530,6 +539,10 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
),
|
),
|
||||||
PaletteDivider(cs: cs),
|
PaletteDivider(cs: cs),
|
||||||
for (final c in _palette) _colorDot(c, cs),
|
for (final c in _palette) _colorDot(c, cs),
|
||||||
|
ThicknessPickerButton(
|
||||||
|
width: _penSlots?.active.width ?? 0.006,
|
||||||
|
onChanged: (w) => _penSlots?.setActiveWidth(w),
|
||||||
|
),
|
||||||
PaletteDivider(cs: cs),
|
PaletteDivider(cs: cs),
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
|
||||||
@@ -558,13 +571,17 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
_tool != EditorToolKind.eraser &&
|
_tool != EditorToolKind.eraser &&
|
||||||
_tool != EditorToolKind.select;
|
_tool != EditorToolKind.select;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setState(() {
|
onTap: () {
|
||||||
if (_tool == EditorToolKind.eraser ||
|
if (_tool == EditorToolKind.eraser ||
|
||||||
_tool == EditorToolKind.select) {
|
_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(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// lib/editor/canvas/sticky_note_overlay.dart
|
// lib/editor/canvas/sticky_note_overlay.dart
|
||||||
//
|
//
|
||||||
// Paper-sticky UX for PDF scratch links: a floating card on the viewer that
|
// Page-anchored paper sticky: sized/positioned by the parent in page space,
|
||||||
// inks into the SAME SidecarRepository the PDF editor holds. Shares the parent
|
// shares the editor brush/color/tool, locks inner pan/zoom so writing feels
|
||||||
// editor's brush/color/tool so there is one toolbar mental model (OneNote-like).
|
// like drawing on the sticky surface itself.
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -24,7 +23,7 @@ import 'pen_stroke.dart';
|
|||||||
/// Default world size for a fresh sticky scratchpad (absolute px).
|
/// Default world size for a fresh sticky scratchpad (absolute px).
|
||||||
const Size kStickyWorldSize = Size(1200, 900);
|
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 {
|
class StickyNoteOverlay extends StatefulWidget {
|
||||||
const StickyNoteOverlay({
|
const StickyNoteOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -32,9 +31,12 @@ class StickyNoteOverlay extends StatefulWidget {
|
|||||||
required this.repo,
|
required this.repo,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
required this.onDragPx,
|
||||||
|
required this.onResizePx,
|
||||||
this.brush = BrushKind.ballpoint,
|
this.brush = BrushKind.ballpoint,
|
||||||
this.color = const Color(0xFF1A1A1A),
|
this.color = const Color(0xFF1A1A1A),
|
||||||
this.tool = EditorToolKind.brush,
|
this.tool = EditorToolKind.brush,
|
||||||
|
this.strokeWidth = 0.008,
|
||||||
this.allowFingerDrawing = false,
|
this.allowFingerDrawing = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,10 +45,16 @@ class StickyNoteOverlay extends StatefulWidget {
|
|||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
final VoidCallback onDelete;
|
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 BrushKind brush;
|
||||||
final Color color;
|
final Color color;
|
||||||
final EditorToolKind tool;
|
final EditorToolKind tool;
|
||||||
|
final double strokeWidth;
|
||||||
final bool allowFingerDrawing;
|
final bool allowFingerDrawing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -62,10 +70,6 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
Timer? _saveTimer;
|
Timer? _saveTimer;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
|
|
||||||
/// On-screen card size (user-resizable). World canvas stays [_world].
|
|
||||||
double _cardW = 300;
|
|
||||||
double _cardH = 360;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -73,10 +77,6 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
if (pad != null) {
|
if (pad != null) {
|
||||||
_world = Size(pad.canvasWidth, pad.canvasHeight);
|
_world = Size(pad.canvasWidth, pad.canvasHeight);
|
||||||
_strokes = pad.strokes.where((s) => isFreehandTool(s.tool)).toList();
|
_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() {
|
void dispose() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
if (_dirty) {
|
if (_dirty) {
|
||||||
widget.repo.scheduleScratchpadSave(
|
_persist(flush: true);
|
||||||
widget.link.id,
|
|
||||||
SidecarScratchpad(
|
|
||||||
canvasWidth: _world.width,
|
|
||||||
canvasHeight: _world.height,
|
|
||||||
strokes: List<InkStroke>.of(_strokes),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
widget.repo.flush();
|
|
||||||
}
|
}
|
||||||
_transform.dispose();
|
_transform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -101,11 +93,11 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
void _scheduleSave() {
|
void _scheduleSave() {
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
_saveTimer = Timer(const Duration(milliseconds: 600), _saveNow);
|
_saveTimer = Timer(const Duration(milliseconds: 600), () => _persist());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveNow() async {
|
Future<void> _persist({bool flush = false}) async {
|
||||||
if (!_dirty) return;
|
if (!_dirty && !flush) return;
|
||||||
widget.repo.scheduleScratchpadSave(
|
widget.repo.scheduleScratchpadSave(
|
||||||
widget.link.id,
|
widget.link.id,
|
||||||
SidecarScratchpad(
|
SidecarScratchpad(
|
||||||
@@ -115,7 +107,7 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
await widget.repo.flush();
|
if (flush) await widget.repo.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onStrokeComplete(PenStroke pen) {
|
void _onStrokeComplete(PenStroke pen) {
|
||||||
@@ -145,7 +137,7 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
|
|
||||||
Future<void> _close() async {
|
Future<void> _close() async {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
await _saveNow();
|
await _persist(flush: true);
|
||||||
widget.onClose();
|
widget.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +148,7 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
case EditorToolKind.select:
|
case EditorToolKind.select:
|
||||||
return CanvasTool.select;
|
return CanvasTool.select;
|
||||||
case EditorToolKind.highlighter:
|
case EditorToolKind.highlighter:
|
||||||
|
return CanvasTool.highlighter;
|
||||||
case EditorToolKind.brush:
|
case EditorToolKind.brush:
|
||||||
case EditorToolKind.shape:
|
case EditorToolKind.shape:
|
||||||
case EditorToolKind.text:
|
case EditorToolKind.text:
|
||||||
@@ -168,13 +161,6 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
? BrushKind.highlighter
|
? BrushKind.highlighter
|
||||||
: widget.brush;
|
: 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
@@ -182,99 +168,121 @@ class _StickyNoteOverlayState extends State<StickyNoteOverlay> {
|
|||||||
elevation: 8,
|
elevation: 8,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: const Color(0xFFFFF8E1),
|
color: const Color(0xFFFFF8E1),
|
||||||
child: SizedBox(
|
clipBehavior: Clip.antiAlias,
|
||||||
width: _cardW,
|
child: Stack(
|
||||||
height: _cardH,
|
children: [
|
||||||
child: Stack(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Column(
|
_StickyHeader(
|
||||||
children: [
|
onDragDelta: widget.onDragPx,
|
||||||
Container(
|
onClose: _close,
|
||||||
height: 36,
|
onDelete: () async {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
await _persist(flush: true);
|
||||||
decoration: const BoxDecoration(
|
widget.onDelete();
|
||||||
color: Color(0xFFFFE082),
|
},
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(4)),
|
cs: cs,
|
||||||
),
|
),
|
||||||
child: Row(
|
Expanded(
|
||||||
children: [
|
child: PenCanvas(
|
||||||
const Icon(Icons.sticky_note_2, size: 18),
|
pageSize: _world,
|
||||||
const SizedBox(width: 6),
|
strokes: penStrokesFromInk(_strokes, _world),
|
||||||
const Expanded(
|
transformationController: _transform,
|
||||||
child: Text(
|
tool: _canvasTool,
|
||||||
'便利贴',
|
brush: _canvasBrush,
|
||||||
style: TextStyle(
|
color: widget.color,
|
||||||
fontSize: 13,
|
strokeWidth: widget.strokeWidth,
|
||||||
fontWeight: FontWeight.w600,
|
eraserRadius: kDefaultEraserRadius,
|
||||||
),
|
allowFingerDrawing: widget.allowFingerDrawing,
|
||||||
),
|
scaleEnabled: false,
|
||||||
),
|
panEnabled: false,
|
||||||
Text(
|
minScale: 1.0,
|
||||||
'用顶栏笔/色',
|
maxScale: 1.0,
|
||||||
style: TextStyle(
|
onStrokeComplete: _onStrokeComplete,
|
||||||
fontSize: 11,
|
onEraseStroke: _onErase,
|
||||||
color: cs.onSurface.withValues(alpha: 0.55),
|
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: ClipRect(
|
],
|
||||||
child: PenCanvas(
|
),
|
||||||
pageSize: _world,
|
Positioned(
|
||||||
strokes: penStrokesFromInk(_strokes, _world),
|
right: 0,
|
||||||
transformationController: _transform,
|
bottom: 0,
|
||||||
tool: _canvasTool,
|
child: GestureDetector(
|
||||||
brush: _canvasBrush,
|
onPanUpdate: (d) => widget.onResizePx(d.delta.dx, d.delta.dy),
|
||||||
color: widget.color,
|
child: MouseRegion(
|
||||||
strokeWidth: brushProfileFor(_canvasBrush).baseWidthFraction,
|
cursor: SystemMouseCursors.resizeUpLeftDownRight,
|
||||||
eraserRadius: kDefaultEraserRadius,
|
child: SizedBox(
|
||||||
allowFingerDrawing: widget.allowFingerDrawing,
|
width: 28,
|
||||||
minScale: 0.2,
|
height: 28,
|
||||||
maxScale: 4.0,
|
child: Icon(
|
||||||
onStrokeComplete: _onStrokeComplete,
|
Icons.south_east,
|
||||||
onEraseStroke: _onErase,
|
size: 16,
|
||||||
pageWidget: const ColoredBox(color: Color(0xFFFFFDE7)),
|
color: cs.onSurface.withValues(alpha: 0.45),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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].
|
/// Persisted under SharedPreferences key [PenConfigController.prefsKey].
|
||||||
class PenConfig {
|
class PenConfig {
|
||||||
const PenConfig({
|
const PenConfig({
|
||||||
this.sideButton = PenButtonAction.select,
|
this.sideButton = PenButtonAction.selectText,
|
||||||
this.eraserEnd = PenButtonAction.eraser,
|
this.eraserEnd = PenButtonAction.eraser,
|
||||||
this.pressureGamma = kNaturalPressureGamma,
|
this.pressureGamma = kNaturalPressureGamma,
|
||||||
this.palmRejectionMs = 150.0,
|
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 file = File('$sourceFilePath$kSidecarSuffix');
|
||||||
final loaded = await SidecarStore.read(file);
|
final loaded = await SidecarStore.read(file);
|
||||||
final sidecar = loaded ??
|
var sidecar = loaded ??
|
||||||
BadnoteSidecar(
|
BadnoteSidecar(
|
||||||
sourceFile: _basename(sourceFilePath),
|
sourceFile: _basename(sourceFilePath),
|
||||||
docType: docType,
|
docType: docType,
|
||||||
|
pageCount: docType == 'notebook' ? 1 : null,
|
||||||
createdAt: DateTime.now().toUtc(),
|
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._(
|
final repo = SidecarRepository._(
|
||||||
sourceFilePath: sourceFilePath,
|
sourceFilePath: sourceFilePath,
|
||||||
sidecar: sidecar,
|
sidecar: sidecar,
|
||||||
@@ -220,6 +246,14 @@ class SidecarRepository {
|
|||||||
_replace(strokes: next);
|
_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.
|
/// Replace the highlight rects for [pageIndex] and schedule a save.
|
||||||
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
|
void scheduleHighlightSave(int pageIndex, List<SidecarHighlight> highlights) {
|
||||||
final next = Map<int, List<SidecarHighlight>>.from(_sidecar.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.
|
/// synchronously here so a later edit can't corrupt an in-flight write.
|
||||||
void _replace({
|
void _replace({
|
||||||
String? title,
|
String? title,
|
||||||
|
int? pageCount,
|
||||||
Map<int, List<EditorStroke>>? strokes,
|
Map<int, List<EditorStroke>>? strokes,
|
||||||
Map<int, List<SidecarHighlight>>? highlights,
|
Map<int, List<SidecarHighlight>>? highlights,
|
||||||
Map<int, List<SidecarText>>? texts,
|
Map<int, List<SidecarText>>? texts,
|
||||||
@@ -362,7 +397,7 @@ class SidecarRepository {
|
|||||||
sourceFile: _sidecar.sourceFile,
|
sourceFile: _sidecar.sourceFile,
|
||||||
docType: _sidecar.docType,
|
docType: _sidecar.docType,
|
||||||
title: title ?? _sidecar.title,
|
title: title ?? _sidecar.title,
|
||||||
pageCount: _sidecar.pageCount,
|
pageCount: pageCount ?? _sidecar.pageCount,
|
||||||
rotation: _sidecar.rotation,
|
rotation: _sidecar.rotation,
|
||||||
createdAt: _sidecar.createdAt,
|
createdAt: _sidecar.createdAt,
|
||||||
updatedAt: DateTime.now().toUtc(),
|
updatedAt: DateTime.now().toUtc(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'editor/pdf/pen_capture_region.dart';
|
||||||
import 'editor/persistence/sidecar_flush_observer.dart';
|
import 'editor/persistence/sidecar_flush_observer.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'diagnostics/badnote_log.dart';
|
import 'diagnostics/badnote_log.dart';
|
||||||
@@ -18,7 +19,9 @@ import 'services/webdav_sync_service.dart';
|
|||||||
import 'storage/sqlite_to_sidecar_migrator.dart';
|
import 'storage/sqlite_to_sidecar_migrator.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
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).
|
// pdfrx native engine init (required before any PdfViewer is built).
|
||||||
pdfrxFlutterInitialize();
|
pdfrxFlutterInitialize();
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
//
|
//
|
||||||
// A PDF-anchored scratch link: a sticky-note "tab" placed at a normalized
|
// 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
|
// 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
|
// anchor opens an on-page sticky card that BELONGS TO THIS ANCHOR (keyed by
|
||||||
// that BELONGS TO THIS ANCHOR (keyed by [id]).
|
// [id]). Optional [nw]/[nh] size the expanded card as fractions of the page.
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
@@ -18,6 +15,8 @@ class ScratchLink {
|
|||||||
required this.pageIndex,
|
required this.pageIndex,
|
||||||
required this.nx,
|
required this.nx,
|
||||||
required this.ny,
|
required this.ny,
|
||||||
|
this.nw = 0.42,
|
||||||
|
this.nh = 0.36,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Stable anchor id (uuid). Doubles as the scratchpad storage key so each
|
/// 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.
|
/// 0-based page the anchor sits on.
|
||||||
final int pageIndex;
|
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;
|
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;
|
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({
|
ScratchLink copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
String? documentId,
|
String? documentId,
|
||||||
int? pageIndex,
|
int? pageIndex,
|
||||||
double? nx,
|
double? nx,
|
||||||
double? ny,
|
double? ny,
|
||||||
|
double? nw,
|
||||||
|
double? nh,
|
||||||
}) =>
|
}) =>
|
||||||
ScratchLink(
|
ScratchLink(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -49,6 +56,8 @@ class ScratchLink {
|
|||||||
pageIndex: pageIndex ?? this.pageIndex,
|
pageIndex: pageIndex ?? this.pageIndex,
|
||||||
nx: nx ?? this.nx,
|
nx: nx ?? this.nx,
|
||||||
ny: ny ?? this.ny,
|
ny: ny ?? this.ny,
|
||||||
|
nw: nw ?? this.nw,
|
||||||
|
nh: nh ?? this.nh,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -57,6 +66,8 @@ class ScratchLink {
|
|||||||
'pageIndex': pageIndex,
|
'pageIndex': pageIndex,
|
||||||
'nx': nx,
|
'nx': nx,
|
||||||
'ny': ny,
|
'ny': ny,
|
||||||
|
'nw': nw,
|
||||||
|
'nh': nh,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory ScratchLink.fromJson(Map<String, dynamic> json) => ScratchLink(
|
factory ScratchLink.fromJson(Map<String, dynamic> json) => ScratchLink(
|
||||||
@@ -65,6 +76,8 @@ class ScratchLink {
|
|||||||
pageIndex: (json['pageIndex'] as num).toInt(),
|
pageIndex: (json['pageIndex'] as num).toInt(),
|
||||||
nx: (json['nx'] as num).toDouble(),
|
nx: (json['nx'] as num).toDouble(),
|
||||||
ny: (json['ny'] 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
|
@override
|
||||||
@@ -76,13 +89,15 @@ class ScratchLink {
|
|||||||
documentId == other.documentId &&
|
documentId == other.documentId &&
|
||||||
pageIndex == other.pageIndex &&
|
pageIndex == other.pageIndex &&
|
||||||
nx == other.nx &&
|
nx == other.nx &&
|
||||||
ny == other.ny;
|
ny == other.ny &&
|
||||||
|
nw == other.nw &&
|
||||||
|
nh == other.nh;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny);
|
int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny, nw, nh);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'ScratchLink(id: $id, documentId: $documentId, pageIndex: $pageIndex, '
|
'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(
|
BadnoteSidecar(
|
||||||
docType: 'notebook',
|
docType: 'notebook',
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
|
pageCount: 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
@@ -311,6 +312,7 @@ class VaultService {
|
|||||||
BadnoteSidecar(
|
BadnoteSidecar(
|
||||||
docType: 'notebook',
|
docType: 'notebook',
|
||||||
title: title,
|
title: title,
|
||||||
|
pageCount: 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
),
|
),
|
||||||
@@ -414,6 +416,7 @@ class VaultService {
|
|||||||
final sidecar = BadnoteSidecar(
|
final sidecar = BadnoteSidecar(
|
||||||
docType: 'notebook',
|
docType: 'notebook',
|
||||||
title: trimmed.isEmpty ? null : trimmed,
|
title: trimmed.isEmpty ? null : trimmed,
|
||||||
|
pageCount: 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// test/pen_brush_color_memory_test.dart
|
// test/pen_brush_color_memory_test.dart
|
||||||
//
|
//
|
||||||
// Pins rnote-style per-brush color memory in the note editor: each brush
|
// Pins OneNote-style independent pen slots in the note editor: each slot
|
||||||
// remembers its OWN color, selecting a brush restores that brush's color (the
|
// remembers its OWN brush + color + width; selecting a slot restores that
|
||||||
// PenCanvas receives it), and picking a color updates ONLY the active brush's
|
// slot's color (the PenCanvas receives it), and picking a color updates ONLY
|
||||||
// entry — switching back to a different brush restores the other color.
|
// 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.
|
// and reads PenCanvas.color to assert the active drawing color.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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_canvas.dart';
|
||||||
import 'package:badnote/editor/canvas/pen_note_screen.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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -22,18 +24,17 @@ void main() {
|
|||||||
Color canvasColor(WidgetTester tester) =>
|
Color canvasColor(WidgetTester tester) =>
|
||||||
tester.widget<PenCanvas>(find.byType(PenCanvas)).color;
|
tester.widget<PenCanvas>(find.byType(PenCanvas)).color;
|
||||||
|
|
||||||
/// Pick the brush named [label] from the BrushPickerButton popup menu.
|
/// Activate the [PenSlotButton] for [kind] via its onPressed (avoids hit-test
|
||||||
Future<void> selectBrush(WidgetTester tester, String label) async {
|
/// collisions with the floating back button over the left of the palette).
|
||||||
// The brush picker carries the 'Brush' tooltip.
|
Future<void> selectSlot(WidgetTester tester, BrushKind kind) async {
|
||||||
await tester.tap(find.byTooltip('Brush'));
|
final finder = find.byWidgetPredicate(
|
||||||
await tester.pumpAndSettle();
|
(w) => w is PenSlotButton && w.kind == kind,
|
||||||
await tester.tap(find.text(label).last);
|
);
|
||||||
|
tester.widget<PenSlotButton>(finder).onPressed();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tap the toolbar color dot whose swatch is exactly [c]. The dot is an
|
/// Tap the toolbar color dot whose swatch is exactly [c].
|
||||||
/// AnimatedContainer (the swatch) inside a GestureDetector; tap the gesture
|
|
||||||
/// detector ancestor so the onTap fires.
|
|
||||||
Future<void> tapColorDot(WidgetTester tester, Color c) async {
|
Future<void> tapColorDot(WidgetTester tester, Color c) async {
|
||||||
final swatch = find.byWidgetPredicate((w) =>
|
final swatch = find.byWidgetPredicate((w) =>
|
||||||
w is AnimatedContainer &&
|
w is AnimatedContainer &&
|
||||||
@@ -41,40 +42,49 @@ void main() {
|
|||||||
(w.decoration as BoxDecoration).color == c &&
|
(w.decoration as BoxDecoration).color == c &&
|
||||||
(w.decoration as BoxDecoration).shape == BoxShape.circle);
|
(w.decoration as BoxDecoration).shape == BoxShape.circle);
|
||||||
final gd = find.ancestor(of: swatch, matching: find.byType(GestureDetector));
|
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();
|
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 {
|
(tester) async {
|
||||||
SharedPreferences.setMockInitialValues({});
|
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(
|
await tester.pumpWidget(const ProviderScope(
|
||||||
child: MaterialApp(home: PenNoteScreen()),
|
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,
|
expect(canvasColor(tester), Colors.black,
|
||||||
reason: 'fountain pen starts black');
|
reason: 'fountain pen starts black');
|
||||||
|
|
||||||
// Switch to the ballpoint brush → its remembered color (blue) becomes active.
|
// Switch to the ballpoint slot → its remembered color (blue) becomes active.
|
||||||
await selectBrush(tester, 'Ballpoint');
|
await selectSlot(tester, BrushKind.ballpoint);
|
||||||
expect(canvasColor(tester), Colors.blue,
|
expect(canvasColor(tester), Colors.blue,
|
||||||
reason: 'selecting ballpoint restores ITS remembered color');
|
reason: 'selecting ballpoint restores ITS remembered color');
|
||||||
|
|
||||||
// Change the ACTIVE (ballpoint) brush's color to red via a color dot.
|
// Change the ACTIVE (ballpoint) slot's color via a palette color dot.
|
||||||
await tapColorDot(tester, Colors.red);
|
const paletteRed = Color(0xFFC62828);
|
||||||
expect(canvasColor(tester), Colors.red,
|
await tapColorDot(tester, paletteRed);
|
||||||
reason: 'color change applies to the active brush');
|
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).
|
// 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,
|
expect(canvasColor(tester), Colors.black,
|
||||||
reason: 'fountain pen color was NOT affected by changing ballpoint');
|
reason: 'fountain pen color was NOT affected by changing ballpoint');
|
||||||
|
|
||||||
// Back to ballpoint → it remembers the red we set.
|
// Back to ballpoint → it remembers the red we set.
|
||||||
await selectBrush(tester, 'Ballpoint');
|
await selectSlot(tester, BrushKind.ballpoint);
|
||||||
expect(canvasColor(tester), Colors.red,
|
expect(canvasColor(tester), paletteRed,
|
||||||
reason: 'ballpoint remembers its own updated color');
|
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();
|
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 {
|
test('removing a highlight persists (un-highlight)', () async {
|
||||||
final repo = await SidecarRepository.open(src, debounce: _fast);
|
final repo = await SidecarRepository.open(src, debounce: _fast);
|
||||||
repo.scheduleHighlightSave(0, const [
|
repo.scheduleHighlightSave(0, const [
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ void main() {
|
|||||||
expect(loaded, isNotNull);
|
expect(loaded, isNotNull);
|
||||||
expect(loaded!.title, 'My Algebra Notes');
|
expect(loaded!.title, 'My Algebra Notes');
|
||||||
expect(loaded.docType, 'notebook');
|
expect(loaded.docType, 'notebook');
|
||||||
|
expect(loaded.pageCount, 1);
|
||||||
// The folder holds only the sidecar (and possibly its .bak/.tmp), never
|
// The folder holds only the sidecar (and possibly its .bak/.tmp), never
|
||||||
// an importable source file.
|
// an importable source file.
|
||||||
final files = Directory(expectedFolder)
|
final files = Directory(expectedFolder)
|
||||||
|
|||||||
Reference in New Issue
Block a user